New way of generating contracts #202
Replies: 7 comments 30 replies
-
That’s not really true for TS and I believe that the quick POC by @konowrockis that you’re referring to was actually based on namespaces. Although namespaces vs JS objects is more of an implementation detail I think (I might be wrong and fail to see some ramifications of that choice at the moment). DartAs for Dart, that goes much deeper. First of all, the current Dart final response = await _cqrs.run(
c.SendMessage()
..messageId = messageId
..conversationId = conversationId
..content = content,
);
Those problems don’t exist in the TS client (although we could think of some improvements surely). I think the call should look more like this: final response = await _apiClient.sendMessage(
messageId: messageId,
conversationId: conversationId,
// say content is optional, we can omit it or allow null with null-safety
); NamespacesIn my view, things like Let’s imagine the generator respected bounded contexts of the domain:
I know, we might use We get a nice scalability for free - different parts of the application could be extracted to separate packages/sub-applications:
We don’t break the interface segregation principle, we don’t need to care about name clashes (kind of, the case of SignalR events remains problematic), we have smaller clients, we can clearly set domain and ubiquitous language boundaries, we can introduce different versioning, we can easily see in diffs which changes affect which parts of the application. I might have got carried away here and it’s not a full-on solution for namespacing issues, but I think we should look more holistically at contracts. Int he end, we could still put all those clients in a single contracts file if we wanted. AttributesThat’s interesting, but I think we need to come up with some real-life cases for that before we get into implementation. For me, it would only bring significant value if there was some client-side tooling involved. Authorization (unless it’s based on roles) is not a valid use case for that IMO. What can the client do with the Using attributes for SignalR is an interesting idea and actually, maybe we should aim to have an auto-generated domain-specific SignalR client as well? I’ve never worked on a LeanCode TS app with SignalR, but in Dart we just get a bunch of DTOs scattered around - some are used by SignalR, some by the HTTP API. The only way to know for sure is to look into the backend code. Which is bad, and Hungarian notation in the form of prefixes/suffixes (albeit better) similarly so. |
Beta Was this translation helpful? Give feedback.
-
I like the idea of having the contracts generator output more high-level stuff like the I wrote some example code on what could be generated. Of course, the naming, how and when to preserve namespaces and how (and if) to split it into multiple files are subject to discussion, but having such an abstraction would defeat the need of using the Query and Command objects directly. // Given we have a CreateUser command inside LeanCode.Example.Core.Contracts.Users
// And a FetchOngoingPayments query in LeanCode.Example.Core.Contracts.Users.Payments
class ClientApi {
ClientApi(this._cqrs) : users = ClientUsersApi(_cqrs);
final CQRS _cqrs;
final ClientUsersApi users;
}
class ClientUsersApi {
ClientUsersApi(this._cqrs) : payments = ClientUsersPaymentsApi(_cqrs);
final CQRS _cqrs;
final ClientUsersPaymentsApi payments;
Future<CommandResult> createUser(String email, String password) {
// Pass it directly to the constructor which would actually
// make more sense and be more error-proof thanks to the analyzer
// and required/optional constructor params.
return _cqrs.run(Users_CreateUser(
email: email,
password: password,
));
}
}
class ClientUsersPaymentsApi {
ClientUsersPaymentsApi(this._cqsr);
final CQRS _cqrs;
Future<List<PaymentDTO>> fetchOngoingPayments() {
return _cqrs.get(
Users_Payments_FetchOngoingPayments(),
);
}
}
void main() async {
final cqrs = CQRS();
final clientApi = ClientApi(cqrs);
final result = await clientApi.users.createUser('email', 'password');
final payments = await clientApi.users.payments.fetchOngoingPayments();
}
IMHO the only namespace part that should be crucial to generation should be the part relative to Summing up, I second:
A propos attributes, I personally don't see any real-world use case for them on the frontends. Not with how our current Dart contracts-related ecosystem works. |
Beta Was this translation helpful? Give feedback.
-
First of all I have a feeling that the general line that this issue follows is to make TS and Dart generators behave exactly the same. My question is: why? Those two languages are so different that it's super hard to compare them and if we limit ourselves to treating them as one we won't benefit any of them. Saying that IMO any further low-level discussions should be treated as completely separate issues for Dart and TS. Of course high-level issues like flattiness, attributes etc. apply equally to both of them but low-level like namespacing or error handling IMO don't. Flat contractsCurrently I'm working on the project where I don't have our generator 😥 While the tech stack closely resembles ours I needed to come up with some way of defining the contract. On frontend we already try to divide our residual domain into some boundaries. I followed that path and defined (manually) contracts for each of these boundaries. The benefit of that approach is that each of those boundaries is quite small so the contract file won't grow uncontrollably (if it does maybe it's good idea to divide). Also it improves discoverability because you know when you can expect given things and often you don't work on two boundaries at once.
AttributesI kinda agree with @mchudy on this point. I feel that attributes are internal backend stuff. Even @Albert221 in his original issue says that:
So I'm not exactly sure what do we want to achive here with attributes. |
Beta Was this translation helpful? Give feedback.
-
Okay, one by one: @mchudy
I stand corrected. They might be useful. Dart
Agreed, except that if we want to have correct abstraction of the protocol, the DTOs cannot be "flat". We might have conflicting names of DTOs and/or queries/commands across different namespaces (this is the one thing that I won't be giving away), and your lowest-level client needs to handle that correctly. If higher-level clients are auto-generated, then these should also take that into account.
I think this is OK for low-level layer, but it tends to be problematic if that is the only layer. We started doing extensive validation because of that, but even with it, it introduces unnecessary back-and-forths for developers.
That's exactly what I had in mind - you lose the connection between command and possible error codes in Dart. This isn't terrible, but introduces friction (and unhandled errors). Namespaces
Indeed, these are. I don't know Dart enough to come up with anything more meaningful.
Let's not conflate bounded contexts with APIs. A single bounded context might have multiple APIs (and I don't think this is bad), like "end-user + admin" or "contests + rankings" (in Activy "Contests" bounded context is split between two apps and two, semi-related APIs). Nevertheless, that might be not enough. We might introduce internal rule that says we don't have name collisions across a single API but then everyone needs to respect that, and it might result in too granular APIs. This is, IMO, the best option for now.
Indeed, but generating "separate file for separate something" requires clear definition of something or you will end up with hundreds of small files with very convoluted imports (or even circular imports - this happens). Attributes
I disagree. You don't need to use the attributes but you should be aware of them. Treat them like a documentation - it will remove the need to go to C# contracts when
And you should know that already - you need to know where it is applied.
I don't think we use SignalR that much. Just documentation would suffice. @Albert221Thanks for the code! It really helps getting grasp on how you might want the client to look like.
That's a very good idea (and should be easily achievable), although I have a question - does Dart allow name clashes across different files (i.e. can you have
Can you elaborate? I didn't think it was problematic. I had more problems with the regexes than with this.
Treat them like a documentation, not like "programmable attribute". @konowrockis
That's wrong impression. I don't want them to behave the same, but if we want to come up on some limitations on how we structure contracts, then both parties must agree. The language-dependent clients might differ but the source contracts must be the same.
Consider error handling - if we have Also, having two completely separate generators (if the end-clients differ substantially) will introduce much more work for us. It's better to at least try to minimize the work. Flat contracts
I agree. If so, we just need to define how granular the parts should be ('cause "bounded context" is too much, as I said above) and how preserve hierarchy there (at least to some point).
We should, but that alone will not be sufficient - if the configuration does not work with how we structure contracts, then we will have problems. :P
What do you mean? Separate config for Dart and for TS? From my experience, these will diverge quickly which might be not effective overall. I don't think we need to have feature (and config) parity, but trying to do everything in both generators will be better overall. Only if that's not possible or not needed, separation should be considered.
That's not infrastructural solution IMO - this is a way to start the cooperation, on a lower level. This way we might have a "base" for future discussion, 'cause right now we can't even cooperate because people don't know where to start (at least that's my impression).
How? Cooperation only won't really work here - Attributes
I disagree. They are used by backend, but also define how the API behaves. And this behavior is, most of the time, crucial for proper usage of the API. Real story -
Propagate knowledge and proper definitions. Authorization is part of the API spec, you don't need to use it explicitly, you just need to know about it (and not guess). Treat it like documentation.
Except that comments will go outdated sooner than attributes. Regarding the attributes - I have the impression that you think that my idea is to use them by the client. This is not true. The only thing I want to achieve is to communicate better, without doing all the back-and-forth discussions. I will be more than happy to generate attributes as comments (but then it's better not to make exceptions, like #194 ) if they will be visible to the end-user. |
Beta Was this translation helpful? Give feedback.
-
Yes! It's perfectly fine. If you want to import two files that both have
It would be nice to have a good default value for that so we wouldn't have to explicitly provide
The |
Beta Was this translation helpful? Give feedback.
-
To sum things up:
HierarchyWe don't have any definite answer on how we want the hierarchy to look like. On one hand, I would prefer not to have to strict rules here, on the other hand - making it too flexible might be abused. I have really two ideas that might be combined, so well... three proposals. ManualIn the config file we have something like this: {
"clients": [
{
"name": "admin",
"sub": [
{
"name": "projects",
"sub": [
{ "name": "mgmt", "namespace": "LeanCode.Example.Core.Contracts.Admin.Projects.Management" },
{ "name": "reports", "namespace": "LeanCode.Example.Core.Contracts.Admin.Projects.Reports" }
]
},
{ "name": "users", "namespace": "LeanCode.Example.Core.Contracts.Admin.Users" }
]
},
{ "name": "user", "namespace": "Leancode.Example.Core.Contracts.User" }
]
} So basically you define the clients explicitly. Pros:
Cons:
Folder structureWe can also define the clients in terms of the folders. We might say that hierarchy will be two levels deep and then use folders to preserve that. For example
and depth = 2 will result in following clients:
Pros:
Cons:
The mixWe can mix the solutions, i.e. in the first one, instead of manually defining all the clients, we can have a third entry type, These aren't the only solutions (hopefully). Do you see something better? Maybe you have ideas on how to fix the "cons"? Client look-and-feelWe need to come up with the look of end-clients, assuming that they are hierarchical. There is some work done already by @Albert221 for the Dart one, but it needs polishing (and precision). May I request your help here @Albert221, @konowrockis, @mchudy? Can you come up with the look of the clients, considering the aforementioned requirements (and all the discussions here)? |
Beta Was this translation helpful? Give feedback.
-
I still think we need start off with more flexible way of configuring things. In FE it's pretty common to have plain .json config for something but you can as easily have the same config in .js. That gives us a lot of power as we have "sharing" out of the box. We can have callbacks. I'm not sure how we can achieve something simillar with C#. I was thinking about .csx. But I'm not sure how we would handle callbacks. I was also thinking about .lua as it's pretty easy to integrate and not complicated language overall. @jakubfijalkowski what do you think? Do you see any easy win here? CallbacksThat's one thought that I had. If we could somehow manually interfere in the process of generating contracts that'd give us a lot of flexibility. For example if we have name clash we could just map the class name on the fly. And I mean not automatically - if we had two Having that we could go further, we could replace matching files by regexes with custom logic - whatever you desire. What do you think? |
Beta Was this translation helpful? Give feedback.
-
Consider this example of contracts (in terms of CoreLib). You can see there a single, static
Auth
class with some constants,ExampleCommand
and both Dart & TS contracts. I want to start a discussion on how the contracts should look like (in both languages) and what are the current deficiencies there.There are already some questions/bugs in the issues, but I assume there are more. Here are mine.
Flat contracts
The generated contracts are flat. Affects both TS & Dart contracts.
This is the main problem with them. Designing backend API without some form of a hierarchy is very problematic (which we've seen in multiple projects). It also makes for a rather unintuitive and hard to grasp client definition. You end up with hundreds of classes and methods in a single file - even tooling doesn't help that much with discoverability here. Especially if you have everything flat.
I don't really know how contracts with hierarchy preserved should look like, since both Dart and TS lacks namespaces. We could emulate that by using multiple files and just
import
ing them where needed but that might be... not ergonomic. It might also not work in Dart (I'm not that fluent there).The other option (TS-only I think) would be to just generate a one, big object with namespaces being fields. I think @konowrockis suggested that somewhere. Basically, the contracts would look like this:
Not best, but at least preserves the intention.
For Dart I think we would need to embed the namespace in the class names, e.g.:
The "long names" problem might not be that... problematic. IMO APIs like this should be isolated, so you would need to use the full name at most a few times in the whole project.
Attributes
This is also mentioned in the issues but I think it deserves more discussion. I would really love to see the attributes generated in the final contracts. Losing them prevents end-clients from seeing the whole picture.
Attributes might be used not only for authorization (currently that's the main usage) but I envision some form of documentation-in-the-code that represents missing relations or missing types (e.g. something like "this command corresponds to this event in the SignalR" or "this is step 5 of the order flow").
ErrorCode
sCurrently, Dart and TS have completely different handling of error codes, to the point where Dart
ErrorCodes
classes are unusable.TS is on the other side of the spectrum -
ErrorCodes
is a special class that must consist ofconst int
s. It is then required to handle all the errors defined there if you want to call the endpoint. This is great, but the current implementation lacks flexibility - you can't compose theErrorCodes
classes (nor inherit them). The TS generator will work (generating code that loses thePhotos
error codes), but Dart's will blow up. Sample code:This is somewhat related to issue #103 .
What do you think? Do not constraint yourself with the current approach - we might (and probably should) rewrite big parts of the generator anyway, so everything is possible. Even using other IDL than C# (but we must have really convincing arguments here ;) ).
Beta Was this translation helpful? Give feedback.
All reactions