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 Vertex, TaxJar, Avalara #59

Merged
merged 43 commits into from
Feb 15, 2022
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f9d0f92
adding vertex and writing a contributing guide
oliverheywood451 Jan 26, 2022
e0a6b18
update readme and folder structure
oliverheywood451 Jan 26, 2022
bfd45b6
wording tweak
oliverheywood451 Jan 26, 2022
281ce1f
wording
oliverheywood451 Jan 26, 2022
591f0f6
more tweaks
oliverheywood451 Jan 26, 2022
f987d94
last tweaks
oliverheywood451 Jan 26, 2022
2eddce5
more detail about tests
oliverheywood451 Jan 26, 2022
6fa6de2
wording
oliverheywood451 Jan 26, 2022
cc92cb6
Update README.md
oliverheywood451 Jan 26, 2022
3ea532d
Update README.md
oliverheywood451 Jan 26, 2022
405fb34
Update README.md
oliverheywood451 Jan 26, 2022
40d4e49
Update README.md
oliverheywood451 Jan 26, 2022
551254a
Update README.md
oliverheywood451 Jan 26, 2022
0ad08c7
Update README.md
oliverheywood451 Jan 26, 2022
ae2d96b
Update README.md
oliverheywood451 Jan 26, 2022
25a89df
Update README.md
oliverheywood451 Jan 26, 2022
0f14441
Typos on Integrations README
crhistianramirez Jan 26, 2022
0bf6eec
Merge pull request #1 from crhistianramirez/patch-2
oliverheywood451 Jan 26, 2022
81db3b9
Update README.md
oliverheywood451 Jan 26, 2022
432abdf
remove duplicates
oliverheywood451 Jan 26, 2022
4e4e713
Merge branch 'tax-integrations' of https://github.com/oliverheywood45…
oliverheywood451 Jan 26, 2022
266bde9
error handling is solid. now need to do tests
oliverheywood451 Jan 31, 2022
6263c51
couple readme updates
oliverheywood451 Jan 31, 2022
553d5a1
solid progress on the testing framework
oliverheywood451 Feb 1, 2022
03c56a6
more tests
oliverheywood451 Feb 1, 2022
ae11694
readme updates
oliverheywood451 Feb 1, 2022
df73caa
dont actually want this
oliverheywood451 Feb 1, 2022
bbd8aa4
Merge branch 'dev' of https://github.com/ordercloud-api/ordercloud-do…
oliverheywood451 Feb 1, 2022
995b14c
switch exception type from int to HttpStatusCode
oliverheywood451 Feb 7, 2022
1848632
change double to decimal
oliverheywood451 Feb 7, 2022
d214766
putting shipping code into a const
oliverheywood451 Feb 7, 2022
15e7509
update readme
oliverheywood451 Feb 7, 2022
56023ea
taxjar is working. still needs tests and error handling.
oliverheywood451 Feb 7, 2022
0567b83
vertex looking pretty good
oliverheywood451 Feb 8, 2022
080dd6d
refactored Interface to not rely on OrderWorksheet. Instead, there's …
oliverheywood451 Feb 8, 2022
eaae6f5
units tests are back. nice!
oliverheywood451 Feb 8, 2022
b07c270
Avalara looks good! still needs tests and readme
oliverheywood451 Feb 9, 2022
7f53c36
updated to support config overrides. This is important if different s…
oliverheywood451 Feb 11, 2022
4b1ada0
move to separate projects
oliverheywood451 Feb 11, 2022
59bf246
updating readmes for project structure
oliverheywood451 Feb 11, 2022
aa73883
remove this as were not ready to be that public
oliverheywood451 Feb 11, 2022
094d5cf
readme updates
oliverheywood451 Feb 11, 2022
4146a6e
readme and package description updates
oliverheywood451 Feb 14, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ If you're building solutions for OrderCloud using .NET and find a particular tas

## Features

### [Commerce Integration List](./library/OrderCLoud.Catalyst/Integrations/Implementations/README.md)

Interact with popular 3rd party APIs that provide functionality useful for commerce. Integrations within a category are made interoperable with an interface.

[Guide to adding an Integration](./library/OrderCLoud.Catalyst/Integrations/CONTRIBUTING.md)

### [User Authentication](https://github.com/ordercloud-api/ordercloud-dotnet-catalyst/tree/dev/library/OrderCloud.Catalyst/Auth/UserAuth)

Use Ordercloud's authentication scheme in your own APIs.
Expand Down
44 changes: 19 additions & 25 deletions library/OrderCloud.Catalyst/Errors/CatalystBaseException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,22 @@ namespace OrderCloud.Catalyst
public class CatalystBaseException : Exception
{
public override string Message => Errors?.FirstOrDefault()?.Message ?? "";
public int HttpStatus { get; set; }
public HttpStatusCode HttpStatus { get; set; }

public IList<ApiError> Errors { get; }

public CatalystBaseException(ApiError apiError, int httpStatus = 400) : base(apiError.Message)
{
HttpStatus = httpStatus;
Errors = new[] {
new ApiError
{
ErrorCode = apiError.ErrorCode,
Message = apiError.Message,
Data = apiError.Data
}
};
}
public CatalystBaseException(ApiError apiError, HttpStatusCode httpStatus = HttpStatusCode.BadRequest)
: this(apiError.ErrorCode, apiError.Message, apiError.Data, httpStatus) { }

public CatalystBaseException(IList<ApiError> errors, int httpStatus = 400)

public CatalystBaseException(IList<ApiError> errors, HttpStatusCode httpStatus = HttpStatusCode.BadRequest)
{
HttpStatus = httpStatus;
Require.That(!errors.IsNullOrEmpty(), new Exception("errors collection must contain at least one item."));
Errors = errors;
}

public CatalystBaseException(string errorCode, string message, object data = null, int httpStatus = 400)
public CatalystBaseException(string errorCode, string message, object data = null, HttpStatusCode httpStatus = HttpStatusCode.BadRequest)
{
HttpStatus = httpStatus;
Errors = new[] {
Expand All @@ -46,15 +37,18 @@ public CatalystBaseException(string errorCode, string message, object data = nu
}

public CatalystBaseException(ErrorCode errorCode, object data = null)
{
HttpStatus = errorCode.HttpStatus;
Errors = new[] {
new ApiError {
ErrorCode = errorCode.Code,
Message = errorCode.DefaultMessage,
Data = data
}
};
}
: this(errorCode.Code, errorCode.DefaultMessage, data, errorCode.HttpStatus) { }



// Keeping these depreacated constructors that take an Int for backwards compatibility.
//public CatalystBaseException(ApiError apiError, int httpStatus) : this(apiError, (HttpStatusCode)httpStatus) { }

//public CatalystBaseException(IList<ApiError> errors, int httpStatus): this(errors, (HttpStatusCode)httpStatus) { }

//public CatalystBaseException(string errorCode, string message, object data, int httpStatus)
// : this(errorCode, message, data, (HttpStatusCode)httpStatus) { }

oliverheywood451 marked this conversation as resolved.
Show resolved Hide resolved

}
}
7 changes: 4 additions & 3 deletions library/OrderCloud.Catalyst/Errors/ErrorCode.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;

namespace OrderCloud.Catalyst
{
public class ErrorCode
{
public ErrorCode(string code, string defaultMessage, int httpStatus = 400)
public ErrorCode(string code, string defaultMessage, HttpStatusCode httpStatus = HttpStatusCode.BadRequest)
{
Code = code;
DefaultMessage = defaultMessage;
HttpStatus = httpStatus;
}

public string Code { get; set; }
public int HttpStatus { get; set; }
public HttpStatusCode HttpStatus { get; set; }
public string DefaultMessage { get; set; }
}

public class ErrorCode<TData> : ErrorCode
{
public ErrorCode(string code, int httpStatus, string defaultMessage) : base(code, defaultMessage, httpStatus) { }
public ErrorCode(string code, HttpStatusCode httpStatus, string defaultMessage) : base(code, defaultMessage, httpStatus) { }
}
}
22 changes: 11 additions & 11 deletions library/OrderCloud.Catalyst/Errors/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,53 +9,53 @@ namespace OrderCloud.Catalyst
{
public class UnAuthorizedException : CatalystBaseException
{
public UnAuthorizedException() : base("InvalidToken", "Access token is invalid or expired.", null, 401) { }
public UnAuthorizedException() : base("InvalidToken", "Access token is invalid or expired.", null, HttpStatusCode.Unauthorized) { }
}
public class WebhookUnauthorizedException : CatalystBaseException
{
public WebhookUnauthorizedException() : base("Unauthorized", "X-oc-hash header does not match. Endpoint can only be hit from a valid OrderCloud webhook.", null, 401) { }
public WebhookUnauthorizedException() : base("Unauthorized", "X-oc-hash header does not match. Endpoint can only be hit from a valid OrderCloud webhook.", null, HttpStatusCode.Unauthorized) { }
}

public class InsufficientRolesException : CatalystBaseException
{
public InsufficientRolesException(InsufficientRolesError data) : base("InsufficientRoles", "User does not have role(s) required to perform this action.", data, 403) { }
public InsufficientRolesException(InsufficientRolesError data) : base("InsufficientRoles", "User does not have role(s) required to perform this action.", data, HttpStatusCode.Forbidden) { }
}

public class InvalidUserTypeException : CatalystBaseException
{
public InvalidUserTypeException(InvalidUserTypeError data) : base("InvalidUserType", $"Users of type {data.ThisUserType} do not have permission to perform this action.", data, 403) { }
public InvalidUserTypeException(InvalidUserTypeError data) : base("InvalidUserType", $"Users of type {data.ThisUserType} do not have permission to perform this action.", data, HttpStatusCode.Forbidden) { }
}

public class RequiredFieldException : CatalystBaseException
{
public RequiredFieldException(string fieldName) : base("RequiredField", $"Field {fieldName} is required", null, 400) { }
public RequiredFieldException(string fieldName) : base("RequiredField", $"Field {fieldName} is required", null, HttpStatusCode.BadRequest) { }
}

public class NotFoundException : CatalystBaseException
{
public NotFoundException() : base("NotFound", $"Not found.", null, 404) { }
public NotFoundException() : base("NotFound", $"Not found.", null, HttpStatusCode.NotFound) { }

public NotFoundException(string thingName, string thingID) : base("NotFound", "Not Found.", new { ObjectType = thingName, ObjectID = thingID }, 404) { }
public NotFoundException(string thingName, string thingID) : base("NotFound", "Not Found.", new { ObjectType = thingName, ObjectID = thingID }, HttpStatusCode.NotFound) { }
}

public class InvalidPropertyException : CatalystBaseException
{
public InvalidPropertyException(Type type, string name) : base("InvalidProperty", $"{type.Name}.{name}", null, 400) { }
public InvalidPropertyException(Type type, string name) : base("InvalidProperty", $"{type.Name}.{name}", null, HttpStatusCode.BadRequest) { }
}

public class UserErrorException : CatalystBaseException
{
public UserErrorException(string message) : base("InvalidRequest", message, null, 400) { }
public UserErrorException(string message) : base("InvalidRequest", message, null, HttpStatusCode.BadRequest) { }
}

public class UserContextException : CatalystBaseException
{
public UserContextException(string message) : base("UserContextError", message, null, 400) { }
public UserContextException(string message) : base("UserContextError", message, null, HttpStatusCode.BadRequest) { }
}

public class WrongEnvironmentException : CatalystBaseException
{
public WrongEnvironmentException(WrongEnvironmentError data) : base("InvalidToken", $"Environment mismatch. Token gives access to {data.TokenIssuerEnvironment} while this API expects {data.ExpectedEnvironment}", data, 401) { }
public WrongEnvironmentException(WrongEnvironmentError data) : base("InvalidToken", $"Environment mismatch. Token gives access to {data.TokenIssuerEnvironment} while this API expects {data.ExpectedEnvironment}", data, HttpStatusCode.Unauthorized) { }
}

public class WrongEnvironmentError
Expand Down
6 changes: 3 additions & 3 deletions library/OrderCloud.Catalyst/Errors/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static IApplicationBuilder UseCatalystExceptionHandler(this IApplicationB
private static Task HandleExceptionAsync(HttpContext context, Exception ex)
{
IList<ApiError> body;
int status = (int) HttpStatusCode.InternalServerError; // 500 if unexpected
var status = HttpStatusCode.InternalServerError; // 500 if unexpected

switch (ex)
{
Expand All @@ -57,7 +57,7 @@ private static Task HandleExceptionAsync(HttpContext context, Exception ex)
}
else // forward status code and errors from OrderCloud API
{
status = (int) ocException.HttpStatus;
status = ocException.HttpStatus ?? HttpStatusCode.BadRequest;
body = ocException.Errors;
}
break;
Expand All @@ -73,7 +73,7 @@ private static Task HandleExceptionAsync(HttpContext context, Exception ex)
break;
}

context.Response.StatusCode = status;
context.Response.StatusCode = (int) status;
context.Response.ContentType = "application/json";
return context.Response.WriteAsync(JsonConvert.SerializeObject(new ErrorList(body)));
}
Expand Down
31 changes: 31 additions & 0 deletions library/OrderCloud.Catalyst/Extensions/ExtensionMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,36 @@ public static string AndFilter(this object filters, (string Key, object Value) f
if (qp.Count == 0) { return null; }
return string.Join("&", qp.Select(x => $"{x.Name}={x.Value}"));
}

/// <summary>
/// Helper method for dealing with the ShipEstimate model.
/// </summary>
/// <returns>The selected ShipMethod</returns>
public static ShipMethod GetSelectedShipMethod(this ShipEstimate shipEstimate)
{
var selectedMethod = shipEstimate.ShipMethods.FirstOrDefault(m => m.ID == shipEstimate.SelectedShipMethodID);
Require.That(selectedMethod != null, new InvalidOperationException($"SelectedShipMethodID is ${shipEstimate.SelectedShipMethodID} but no matching object was found in ShipMethods List."));
return selectedMethod;
}

/// <summary>
/// Helper method for dealing with the ShipEstimate model. Maps ShipEstimateItems from lineItem IDs to full LineItems.
/// </summary>
/// <returns>Full model of all LineItems on the specified ShipEstimate</returns>
public static List<(int Quantity, LineItem LineItem)> GetShipEstimateLineItems(this OrderWorksheet order, string shipEstimateID)
{
var shipEstimate = order.ShipEstimateResponse.ShipEstimates.FirstOrDefault(se => se.ID == shipEstimateID);
Require.That(shipEstimate != null, new ArgumentException($"No matching ship estimate found with ID {shipEstimateID}"));

var lineItems = new List<(int quantity, LineItem lineItem)> { };
foreach (var shipEstimateItem in shipEstimate.ShipEstimateItems)
{
var lineItem = order.LineItems.FirstOrDefault(li => li.ID == shipEstimateItem.LineItemID);
Require.That(lineItem != null, new InvalidOperationException($"ShipEstimateItem.LineItemID is ${shipEstimateItem.LineItemID} but no matching object was was found in OrderWorksheet.LineItems."));
lineItems.Add((shipEstimateItem.Quantity, lineItem));
}

return lineItems;
}
}
}
78 changes: 78 additions & 0 deletions library/OrderCloud.Catalyst/Integrations/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Contributing Guide For Integrations

Thank you for investing your time in contributing! These are guidelines for adding a new integration to the Catalyst library. See a list of [existing integrations](./Implementations/README.md).

## Basics

Creating an integration in this project means it will be published as part of a [Nuget code library](https://www.nuget.org/packages/ordercloud-dotnet-catalyst/). Each integration should expose functionality to interact with 1 external service and should not depend on any other integrations. There is a natural tension between providing too little "wrapper" functionality (creating a generic API client) and too much "wrapper" (an opinionated solution that limits use cases). The key to this balance are the details of the contract your integration exposes.

## Exposed Contracts

All integrations should include two classes designed to be exposed and consumed by solutions - an `OCIntegrationConfig` and an `OCIntegrationCommand`. The config is a POCO which contains properties for all the environment variables and secrets needed to authenticate to the service. The command exposes the functionality of your integration through methods. For an example service called "Mississippi" you would create the classes below.

```c#
public class MississippiConfig : OCIntegrationConfig
{
public override string ServiceName { get; } = "Mississippi";

[RequiredIntegrationField]
public string ApiKey { get; set;}

... etc.
}
```
```c#
public class MississippiCommand : OCIntegrationCommand
{
protected readonly MississippiConfig _config;

public MississippiOCIntegrationCommand(MississippiConfig config) : base(config)
{
_config = config; // used to auth to service
}

public async Task<decimal> GetRiverLength()
{

}

... etc.
}
```

Your integration will likely contain other public classes but these two mandatory classes form the exposed surface of your integration - designed for use in other projects.

## Interfaces

A key goal is *interoperability*. In other words, if two services solve roughly the same problem (e.g. calculating tax), they should expose the same contract. To facilitate that, there are interfaces like [ITaxCalculator](./Interfaces/ITaxCalculator.cs). Please check under [/Integrations/Interfaces](./Interfaces) to see if any apply to your integration's problem domain. If some do, make sure your OCIntegrationCommand implements those interfaces.

Feel free open issues recommending changes or additions to the interfaces.

## Guidelines

- General
- Keep the number of properties and methods on your exposed contracts to the minimum required. Do a small amount well.
- Aim to follow the code patterns of existing integrations.
- Folders and files
- Under [/Integrations/Implementations](./Implementations) create a folder with your service name (e.g. "Mississippi") to contain your files.
- At the root of your new folder include your Command, Config and a README.md. Copy the README format of existing integrations.
- All files and class names should begin with your service name.
- Many of the existing integrations also have a Client class. For these integrations the Client class is a pure API wrapper, handling HTTP requests and exceptions. This leaves mapping as the responsibility of the Command class.
- Errors
- Handle error scenarios within your integration by throwing one of the exceptions in [/Integrations/Exceptions](./Exceptions).
- Every integration should handle cases like missing configs, invalid authentication, error response, and no response.
- Tests
- Write unit tests against your Command methods and put them in the OrderCloud.Catalyst.Tests project under a new folder like so `/IntegrationTests/[ServiceName]/[ServiceName]Tests.cs`.
- Mock API reponses from your service using [Flurl test practices](https://flurl.dev/docs/testable-http/) or something similar.
- Test error scenarios as well.
- See [VertexTests](../../../tests.OrderCloud.Catalyst.Tests/IntegrationTests/Vertex/VertexTests.cs).
- Code Style
- Avoid adding a nuget package for your service's SDK. This will lead to bloat as many projects may use this library without using your service. Instead, use the Flurl library for RESTful requests. This will also keep testing consistient.
- When you want to make methods or properties `private`, consider using `protected` instead so that client projects can extend your functionality.
- Use DateTime.Utc explicitly to keep the project time-zone agnostic.
- Always use the `decimal` type for anything money related.

## Approval

A disclaimer, whether a pull request with a new integration is accepted and published will ultimately depend on the approval of the OrderCloud team.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;

namespace OrderCloud.Catalyst
{
public class IntegrationAuthFailedException : CatalystBaseException
{
public IntegrationAuthFailedException(OCIntegrationConfig config, string requestUrl) : base(
"IntegrationAuthorizationFailed",
$"A request to 3rd party service {config.ServiceName} resulted in an Unauthorized error. Check your config credentials.",
new IntegrationAuthFailedError()
{
ServiceName = config.ServiceName,
RequestUrl = requestUrl,
},
HttpStatusCode.BadRequest) {}
}

public class IntegrationAuthFailedError
{
public string ServiceName { get; set; }
public string RequestUrl { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;

namespace OrderCloud.Catalyst
{
public class IntegrationErrorResponseException : CatalystBaseException
{
public IntegrationErrorResponseException(OCIntegrationConfig config, string requestUrl, object responseBody) : base(
"IntegrationErrorResponse",
$"A request to 3rd party service {config.ServiceName} resulted in an error. See ResponseBody for details.",
new IntegrationErrorResponseError()
{
ServiceName = config.ServiceName,
RequestUrl = requestUrl,
ResponseBody = responseBody
}
, HttpStatusCode.BadRequest)
{ }
}

public class IntegrationErrorResponseError
{
public string ServiceName { get; set; }
public string RequestUrl { get; set; }
public object ResponseBody { get; set; }
}
}
Loading