Technical Articles
B1SLayer: A clean and easy way to consume SAP Business One Service Layer with .NET
In this blog post I will present to you an overview of B1SLayer, an open source .NET library created by me that aims to make Service Layer integrations as clean and easy as possible.
Introduction
If you, as a developer, ever worked in multiple .NET projects that consume Service Layer, you probably faced the issue where different projects may have different implementations on how they communicate with Service Layer. Often times these implementations are less than ideal and can lead to a number of issues and headaches that could be avoided.
Also, although RESTful APIs have concepts that are fairly easy to grasp, Service Layer has some particularities that can make it difficult to integrate with, specially for new developers. There is also the option to use the WCF/OData client, but this approach can be cumbersome, which is probably why most developers I know have chosen to write their own solutions instead.
B1SLayer aims to solve all that by abstracting Service Layer’s complexity and taking care of various things under the hood automatically, saving precious development time and resulting in a cleaner and easier to understand code. B1SLayer seamlessly takes care of the authentication and session management, provides an easy way to perform complex requests with its fluent API, automatically retries failed requests and much more.
Getting started
The first thing to do is install it to your project. B1SLayer is available on NuGet and can be easily installed through the NuGet Package Manager in Visual Studio or the .NET CLI. The library is based on the .NET Standard 2.0 specification and therefore it is compatible with various .NET implementations and versions.
Once installed, you start by creating your SLConnection instance, providing it the required information for it to be able to connect to the API, that is, the Service Layer URL, database name, username and password.
The SLConnection instance is the most important object when using B1SLayer. All requests originate from it and it’s where the session is managed and renewed automatically. Therefore, once initialized, this instance needs to be kept alive to be reused throughout your application’s lifecycle. A common and simple way to achieve this is implementing a singleton pattern:
using B1SLayer;
public sealed class ServiceLayer
{
private static readonly SLConnection _serviceLayer = new SLConnection(
"https://localhost:50000/b1s/v1/",
"SBO_COMPANYDBNAME",
"username",
"password");
static ServiceLayer() { }
private ServiceLayer() { }
public static SLConnection Connection => _serviceLayer;
}
If you are developing an ASP.NET Core application, like a Web API, it’s even simpler. You can take advantage of the dependency injection (DI) design pattern for later requesting the SLConnection instance where you need it:
builder.Services.AddSingleton(serviceLayer => new SLConnection(
"https://localhost:50000/b1s/v1/",
"SBO_COMPANYDBNAME",
"username",
"password"));
For the following content in this post, I’m going to assume you have your SLConnection instance named “serviceLayer”.
Performing requests
As mentioned earlier, B1SLayer manages Service Layer’s authentication and session for you. This means you don’t need to perform a login request as it is done automatically whenever necessary, although you can still login manually if you want to.
Another thing to keep in mind, is that B1SLayer does not include model classes for Service Layer’s entities. This means you will need to create model classes like BusinessPartner or PurchaseOrder yourself.
Most B1SLayer requests can be divided into three parts: creation, configuration and execution, all done chaining calls in a fluent manner.
- Creation is where you call the Request method from your SLConnection instance to specify which Service Layer resource you wish to request to, for instance: “BusinessPartners”;
- Configuration is optional, it’s where you can specify parameters for the request, like query string, headers, etc. For instance: “$filter”, “$select”, “B1S-PageSize”;
- Execution is where the the HTTP request is actually performed (GET, POST, PATCH, DELETE). It’s also where you can specify the return type or the JSON body, optionally.
In the example bellow, a GET request is created to the “PurchaseOrders” resource for a specific document with DocEntry number 155. The request is then configured to select only a couple fields of this entity and lastly the request is executed, deserializing the JSON result into a MyPurchaseOrder type object named purchaseOrder.
// Resulting HTTP request:
// GET /PurchaseOrders(155)?$select=DocEntry,CardCode,DocTotal
MyPurchaseOrderModel purchaseOrder = await serviceLayer // SLConnection object
.Request("PurchaseOrders", 155) // Creation
.Select("DocEntry,CardCode,DocTotal") // Configuration
.GetAsync<MyPurchaseOrderModel>(); // Execution
Pretty straight forward, right? How about a more complex request? In the example bellow, a GET request is created to the “BusinessPartners” resource, then configured with Select, Filter and OrderBy query string parameters, then WithPageSize which adds the “B1S-PageSize” header parameter to the request to specify the number of entities to be returned per request, overwriting the default value of 20. Lastly, the request is performed and the JSON result is deserialized into a List of MyBusinessPartnerModel type named bpList.
// Resulting HTTP request:
// GET /BusinessPartners?$select=CardCode,CardName,CreateDate&$filter=CreateDate gt '2010-05-01'&$orderby=CardName
// Headers: B1S-PageSize=100
List<MyBusinessPartnerModel> bpList = await serviceLayer
.Request("BusinessPartners")
.Select("CardCode,CardName,CreateDate")
.Filter("CreateDate gt '2010-05-01'")
.OrderBy("CardName")
.WithPageSize(100)
.GetAsync<List<MyBusinessPartnerModel>>();
A POST request is even simpler, as you can see bellow. The “Orders” resource is requested and a new order document is created with the object newOrderToBeCreated serialized as the JSON body. If the entity is created successfully, by default Service Layer returns the created entity as the response, this response then is deserialized into a new a MyOrderModel type object named createdOrder.
// Your object to be serialized as the JSON body for the request
MyOrderModel newOrderToBeCreated = new MyOrderModel { ... };
// Resulting HTTP request:
// POST /Orders
// Body: newOrderToBeCreated serialized as JSON
MyOrderModel createdOrder = await serviceLayer
.Request("Orders")
.PostAsync<MyOrderModel>(newOrderToBeCreated);
What about PATCH and DELETE requests? Here I’m using an anonymous type object that holds the properties that I want to update in my business partner entity. PATCH and DELETE requests don’t return anything, so there is nothing to deserialize.
// Your object to be serialized as the JSON body for the request
var updatedBpInfo = new { CardName = "SAP SE", MailAddress = "sap@sap.com" };
// Resulting HTTP request:
// PATCH /BusinessPartners('C00001')
// Body: updatedBpInfo serialized as JSON
await serviceLayer.Request("BusinessPartners", "C00001").PatchAsync(updatedBpInfo);
// Resulting HTTP request:
// DELETE /BusinessPartners('C00001')
await serviceLayer.Request("BusinessPartners", "C00001").DeleteAsync();
Specialized requests
Although the majority of requests to Service Layer will follow the format I presented above, there are some exceptions that needed a special implementation, these are called directly from your SLConnection object. Let’s get into them.
Login and logout
Just in case you want to handle the session manually, here’s how to do it:
// Performs a POST on /Login with the information provided in your SLConnection object
await serviceLayer.LoginAsync();
// Performs a POST on /Logout, ending the current session
await serviceLayer.LogoutAsync();
Attachments
Uploading and downloading attachments is very streamlined with B1SLayer. Have a look:
// Performs a POST on /Attachments2, uploading the provided file and returning the
// attachment details in a SLAttachment type object
SLAttachment attachment = await serviceLayer.PostAttachmentAsync(@"C:\temp\myFile.pdf");
// Performs a GET on /Attachments2({attachmentEntry}) with the provided attachment entry,
// downloading the file as a byte array
byte[] myFile = await serviceLayer.GetAttachmentAsBytesAsync(953);
Keep in mind that to be able to upload attachments through Service Layer, first some configurations on the B1 client and server are required. Check out the section “Setting up an Attachment Folder” in the Service Layer user manual for more details.
Ping Pong
This feature was added in version 9.3 PL10, providing a direct response from the Apache server that can be used for testing and monitoring. The result is a SLPingResponse type object containing the “pong” response and some other details. Check section “Ping Pong API” in the Service Layer user manual for more details.
// Pinging the load balancer
SLPingResponse loadBalancerResponse = await serviceLayer.PingAsync();
// Pinging a specific node
SLPingResponse nodeResponse = await serviceLayer.PingNodeAsync(2);
Batch requests
Although a powerful and useful feature, batch requests can be quite complicated to implement, but thankfully this is also very simple to do with B1SLayer. If you are not familiar with the concept, I recommend reading section “Batch Operations” in the user manual. In essence, it’s a way to perform multiple operations in Service Layer with a single HTTP request, with a rollback capability if something fails.
Here each individual request you wish to send in a batch is represented as an SLBatchRequest object, where you specify the HTTP method, resource and optionally the body. Once you have all requests created, you send them through the method PostBatchAsync. The result is an HttpResponseMessage array containing the responses of each request you sent.
var postRequest = new SLBatchRequest(
HttpMethod.Post, // HTTP method
"BusinessPartners", // resource
new { CardCode = "C00001", CardName = "I'm a new BP" }); // request body
var patchRequest = new SLBatchRequest(
HttpMethod.Patch,
"BusinessPartners('C00001')",
new { CardName = "This is my updated name" });
var deleteRequest = new SLBatchRequest(HttpMethod.Delete, "BusinessPartners('C00001')");
// Here I'm passing each request individually, but you can also
// add them to a collection and pass it instead.
HttpResponseMessage[] batchResult = await serviceLayer
.PostBatchAsync(postRequest, patchRequest, deleteRequest);
Conclusion
This is my first post here and it ended up longer than I expected, and even still, I couldn’t possibly fit every feature of B1SLayer here, otherwise this post would be even longer. Nevertheless, I hope it gives you a good overall understanding of how it works and what it offers. I’ll do my best to keep this post updated and up to the community standards, so any feedback is appreciated!
The source code for B1SLayer is available on GitHub. If you want to contribute, have any suggestions, doubts or encountered any issue, please, feel free to open an issue there or leave a comment bellow. I will try to reply as soon as possible.
This is great, I have a question about the updates,
I want to implement it in the company where I am. I won't have any problems later if SAP does an update. Are you going to be constantly updating to improve it or is it up to us as developers to solve if we have any problems
I plan to keep updating it with improvements and fixes for the time being. So far, I've yet to see an SAP update break anything in the way B1SLayer works, so I believe that's unlikely.
Also, keep in mind B1SLayer is open source, so anyone can contribute to it. If you encounter any problem, open an issue on GitHub detailing what's happening and I'll get to it as soon as possible.
I'm trying it right now thank you very much for the answer
Very good project, Thanks Bruno.
Only thing I feel missing is "IncludeCount" .
Good Luck
Hi, Beka. Thank you for your comment.
Can you explain what exactly you mean by "IncludeCount"?
Hi Bruno.
For example:
var query = slContext.Items.IncludeCount().ToList()
returns item list with count property.
Regards
Muchas gracias. Lo estoy implementando en un nuevo proyecto y es excelente
Thank you, Adan!
Dear Bruno,
Thanks for your article and https://www.nuget.org/packages/B1SLayer
Excellent piece of work!
I have a scenario where I want to process a huge amount of rows. i.e.
serviceLayer.AfterCall(async call =>
{
Console.WriteLine($"Call duration: {call.Duration.Value.TotalSeconds} seconds");
});
var invoiceList = await serviceLayer.Request("Invoices").WithPageSize(100)
.Select("DocEntry,DocDate,DocTime,CardCode,DocTotal,DocumentLines")
.Filter($"DocEntry ge 1000")
.OrderBy("DocEntry")
.GetAllAsync<SapInvoice>();
Question: As it processes each 100 page chunk, is there a way to access the returned invoiceList and save the returned data before going on to get the next 100 pr less chunk? I tried to do it without success within the
serviceLayer.AfterCall(async call => {});
It looks like the parameters used in the serviceLayer.Request() were immutable. It does not matter if I make invoiceList as a global static variable. I even tried to make the
.Filter($”DocEntry ge {nextDocEntryChunk}”) variable,
but it did not change. It looks like it is a single request that will be processed as N calls until the last result set arrives.
Greetings from Panama,
Hermann
Hi, Hermann. Thanks for your comment.
In your case, if you want to process the page chunks as they are obtained, instead of using the GetAllAsync method, it's probably better if you loop the requests yourself using the GetAsync method in conjunction with Skip until you to go through all records.
The AfterCall method is intended to be used for logging and monitoring the requests.
Hi Bruno,
Thanks for your quick response. I did it as you advise. So, at the end the code worked fine doing something like:
Dear Bruno
Thank you for your Article on Service Layer and the tools you provided . Is there any chance to provide a version in VB.Net? I believe it will very much appreciated.
Best Regards
George Pachakis
Hi, George. Thank you for your comment.
Although B1SLayer is developed in C#, I believe there's nothing stopping you from using it in your VB.NET projects. You can install it from NuGet the same way as I described in this blog post.
I'm not a VB developer, so let me know if you encounter any issues.
Dear Bruno
I’m using B1SLayer since May 2021 and I just want to say thank you for this excellent piece of software!
Greetings from Switzerland
Christian
Hi, Christian! Thank you so much for the kind words.
I'm glad that B1SLayer is proving to be useful for you. Cheers!
Dear Bruno
As I am trying to develop a web API using your library, some questions need to ask you.
My scenario is to get the InStock value from all warehouses, did your library support passing the query (QueryService_PostQuery)? As I need to cross-join the Items/ItemWarehouseInfoCollection to get all warehouses In stock quantity or just use the select option on Items to get the full ItemWarehouseInfoCollection array?
result
Hi, Karmen.
I believe there's two ways you could perform your request with B1SLayer. See bellow:
Or like this, which I would preffer because you can take advantage of GetAllAsync:
In both I'm assuming you have defined the model class MyItemWarehouseModel to deserialize the JSON response from Service Layer.
Let me know if that helps!
Dear Bruno
It's is working, thanks for the help
Dear Bruno!
Once again big thanks for the great B1SLayer product you have developed!
We are trying to send a BATCH request to create Sales Order and then a Down Payment based on it in one transaction.
Below is our code, but …
The question is how to specify that the down payment should be based on the order that will be created with the first request in the batch?
Or in general: Is it possible to create a sales order, then based on created sales order create a down payment or Invoice documents in one batch request?
Best Regards
Vardan
Hi, Vardan.
In this case you need to specify the Content-ID in your request to have a reference for the entity you want. See bellow:
In case this doesn't work for you, try using OData V4 by simply changing from "b1s/v1" to "b1s/v2" in your Service Layer URL.
Here is the result, both documents created and linked in Business One:
Let me know if that works for you!
It works!!!
Thank you so much!
Hello Bruno
A good contribution, congratulations.
One question, how can I close an "Order" document?, using SLBatchRequest
Hi, Wilton.
I did some tests and for some reason, when in a batch, this request requires an empty body (not null) for it to work, otherwise I get HTTP error 400 (Bad Request). I'm not sure why, but this should work:
Hi, Bruno
It's works. I missed the third parameter new {}
Thank you so much!
Hi Bruno
Id = 84, Status = WaitingForActivation, Method = "{null}", Result = "{Aún no computado}"
AsyncState: null
CancellationPending: false
CreationOptions: None
Exception: null
Id: 84
Result: {System.Threading.Tasks.VoidTaskResult}
Status: WaitingForActivation
Option 2: Batch
var Quotation = new SLBatchRequest(
HttpMethod.Patch,
"Quotations(23119)",
Body);
HttpResponseMessage[] batchResult = ServiceLayer.PostBatchAsync(newPostQuotation1).Result;
Answer:
{StatusCode: 204, ReasonPhrase: 'No Content', Version: 1.1, Content: <null>, Headers:
{
DataServiceVersion: 3.0
}}
NOTE: There is no error, but it does not update the quote
Neither way works for me.
I hope you can help me
you want to change the Lines in the existing document, since this is a collection you need to send the header value B1S-ReplaceCollectionsOnPactch as true, you can do this simply by adding the extension .WithReplaceCollectionsOnPatch() to your Request.
Hello Sven
Thanks for answering.
I did the test (adding WithReplaceCollectionsOnPatch)with option 1:Patch
ServiceLayer.Request("Quotations", 3481)
.WithReplaceCollectionsOnPatch()
.PatchAsync(Body);
now i get error:
"10000111 - On \"Contents\" tab, enter item or items"
I will appreciate your idea, I could do the test.
Dear Sven
If it works as you indicate in asynchronous mode.
I wanted in synchronous mode.
I will work in asynchronous mode.
Thank you
Hello, happy that I could help. I don’t know exactly the reason why this works in async request only. Have you set the CORS allowed headers in your ServiceLayer configuration? Maybe, if you explicitly add the b1s-replacecollectionsonpatch header there -if it’s not already set- then this works for non async requests. At least you could check there. I remember there was also an issue with this header in batch requests, SAP note 2850921.
Hello everybody
I hope you can help me. I am creating business partner from Service Layer. Using B1Slayer, everything perfect.
But, from time to time, I am starting to receive an error saying: "This entry already exists in the following tables (ODBC -2035)".
The only change I made, is to use Service Layer (before I was using DI SERVER), to create clients.
What could be happening?
We use SAP Business One 10.0 (10.00.191) FP 2202 (64-bit) with MS SQL Server 2017.
I look forward to your help or idea to test.
Hi! Bruno Mulinari , Wilton Tejada
Big thanks for the great B1SLayer product you have developed!
i'm working with Quotations update using Patch with out any luck.
i want to be able to modify a line (qty , price)
and be able to delete existing lines
and be able to add a new lines
i have try two way without luck.
I create the Quotation document Object
i only add the DocEntry and the DocumentLines object list
cosidering the events Add,delete,update
To Add
a add a new Document line to the DocumentLines object list
Add
To delete
i subtract the line form the DocumentLines object list
delete
To Update
I modify the content on specific DocumentLines
one action or all actions can happended and the json will reflect them.
option one :
Code;
var UpdateQuotation=ServiceLayer.slConnection.Request("Quotations",quotation.DocEntry).WithReplaceCollectionsOnPatch().PatchAsync(quotation.DocumentLines);
Result :
Id = 27, Status = WaitingForActivation, Method = "{null}", Result = "{Aún no computado}"
----------------------
option 2
code:
await ServiceLayer.slConnection.Request("Quotations", quotation.DocEntry).PatchAsync(quotation.DocumentLines);
result:
B1SLayer.SLException: 'BadFormat'
FlurlHttpException: Call failed with status code 400 (Bad Request): PATCH https://192.168.47.147:50000/b1s/v1/Quotations(486)
his exception was originally thrown at this call stack:
[External Code]
B1SLayer.SLRequest.PatchAsync.AnonymousMethod__0() in SLRequest.cs
B1SLayer.SLConnection.ExecuteRequest<T>(System.Func<System.Threading.Tasks.Task<T>>) in SLConnection.cs
i appreciate any orientations.
Hi, Guillermo.
In order to update a document or its lines, you need to send the document object as the JSON body, not just the lines. I believe the code bellow should work:
Hello Bruno is there any possibility to connect without ssl?
Hi, Gerardo. I'm not sure I understand your question. By default, the Service Layer API supports HTTPS only, so it won't accept HTTP.
This is a good thing since HTTP is not secure for sensitive data.
Hi Bruno,
I was talking about sending requests without SSL verification. Currently, our service layer does not have any SSL certificate configuration since it is installed on the intranet exclusively for company staff. However, I discovered that it has already been configured to disable SSL verification.
Gerardo, in B1SLayer the certificate validation is disabled since the default certificate for Service Layer is self-signed and the connection would not work without doing this. This is also done in the .NET example provided in the Service Layer user manual.
However, even though the certificate validation is disabled, SSL will still be used regardless.
Hello, great job Bruno !
I'm a fresh technical B1 consultant and still in my learning phase.
I was wondering if this would help me expose functions of an add-on I have on SAP Business One ? the end goal is to create a loosely coupled solution in a form of a web app that performs like the add-on (it's a sales add-on where you chose from products categories colors ... and then chose a markup and update the price) will it help me expose the functionalities of the said above add-on ?
If you need any further clarification, I can provide.
Regards,
El Mehdi
Hello, Thank Bruno.
I want to delete master data states, but need 2 parameters Country and Code. How to declare with many parameters on the B1Slayer that you created. Regard, Arista
Hi, Arista.
In case of multiple parameters, the request can be made like this:
I hope this helps.
Thanks You Bruno, Successfully