diff --git a/.github/workflows/deploy_to_nuget.yml b/.github/workflows/deploy_to_nuget.yml new file mode 100644 index 0000000..b735d5d --- /dev/null +++ b/.github/workflows/deploy_to_nuget.yml @@ -0,0 +1,54 @@ +name: Deploy to NuGet + +on: + workflow_dispatch: + pull_request: + types: [ closed ] + +jobs: + deploy: + if: github.event.pull_request.merged == true + name: Deploy to NuGet + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [6.0] + steps: + # Checkout the code + - name: Checkout code + uses: actions/checkout@v4 + + # Set up .NET + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '6.0.x' + cache: true + cache-dependency-path: "./UrlboxSDK" + + # Restore dependencies + - name: Restore dependencies + run: dotnet restore ./UrlboxSDK + + # Run tests + - name: Run tests + run: dotnet test --no-restore + + # Build the project + - name: Build the project + run: dotnet build ./UrlboxSDK --configuration Release --no-restore + + # Pack the NuGet package somewhere unique + - name: Pack NuGet package + run: dotnet pack --configuration Release --no-build --output ./package + + # Push the NuGet package to NuGet.org + - name: Publish to NuGet + run: dotnet nuget push "./package/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + + # https://github.com/googleapis/release-please/blob/72b0ab360c3d6635397e8b02f4d3f9f53932e23c/docs/customizing.md + - name: Create Release + uses: google-github-actions/release-please-action@v4 + with: + release-type: simple + diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..cfcebf5 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,28 @@ +name: run tests + +on: + push: + +jobs: + test: + permissions: + contents: read + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: [6.0] + steps: + - name: Checkout urlbox-dotnet + uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '6.0.x' + cache: true + cache-dependency-path: "./UrlboxSDK" + + - name: Install dependencies + run: dotnet restore + + - name: Run tests + run: dotnet test diff --git a/.gitignore b/.gitignore index 4e82d27..a6441b3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ Thumbs.db # dotCover *.dotCover + +.idea diff --git a/Assets/highlight.pdf b/Assets/highlight.pdf new file mode 100644 index 0000000..5f9279e Binary files /dev/null and b/Assets/highlight.pdf differ diff --git a/Assets/html.html b/Assets/html.html new file mode 100644 index 0000000..f766411 --- /dev/null +++ b/Assets/html.html @@ -0,0 +1,35 @@ +Trusted Website Screenshot Service, flawless full page captures and more. | Urlbox
  • 5 out of 5 stars

    "VERY FAR ahead"

  • 5 out of 5 stars

    "high-quality images"

  • 5 out of 5 stars

    "a real success story"

Website Screenshot Service

The Trusted Source for
Website Screenshots.

Urlbox captures flawless full page automated screenshots. Get web data from the screenshot API you can depend on.

  • Render as meticulously as a designer on macOS
  • Generate thousands of unique images in minutes
  • Control over 100 browser rendering options

7 day free trial.No credit card required.

Try right here

https://
px
px

Over 650 active customers capturing the web for research, compliance, design, and more:

Who cares?

No One Seems To Take Screenshots As Seriously As You.

Screenshots might play a critical role in compliance for your business. Maybe they're key to delighting your customers or illustrating your commitment to quality. Done well, screenshots provide a competitive edge in research, reporting and marketing.

You know it's far too easy for screenshots to go wrong in every case.

Inaccurate

Compliance teams flag missing or obscured elements. You painstakingly re-do every single screenshot to gain credibility with auditors.

Ugly

Designers wince at blocky fonts and layout misalignments. Screenshots look like someone hit print screen in Windows 95 just before a blue screen of death.

Unengaging

Marketers hold back from sharing content on the channels they could. The auto-generated images they have just don't have the edge they used to have.

Inconsistent

Researchers and analysts worry about distracting ads, banners, widgets and popups. They'll need to revisit every web page to have any confidence in their insights.

Inflexible

Engineering teams complain about the lack of control. 'Simple' rendering errors go unfixed for months, services go down for hours with no explanation.

Insecure

Security teams raise concerns about data privacy and retention. No one is confident about where and when screenshots are being taken, how they are stored or for how long.

Unsupported

Customers, and everyone else, badger you with screenshot issues. You wait days for support from vendors who spend less time thinking about screenshots than your accountant.

"It's so evident that Urlbox really cares and pays attention."

Mike Schauer

Mike Schauer, Founder Swiped.co

Serious about screenshots

Imagine If You Had A Team 100% Focused On Screenshots.

You could focus on your core business and leave screenshots to the experts.

Flawless

Auditors are so impressed they recommend your company's approach to screenshots to their lawyers.

Beautiful

Designers confidently use the screenshots for entries into design awards.

Inspiring

The sales team praises the marketing team for their ability to personalise and repurpose so much great content.

Predictable

Every week clients rave about the new insights researchers and analysts are presenting in visually stunning reports.

Comprehensive

Every time an engineer discovers a new screenshotting edge case, they learn there's a ready to use solution for it.

Robust

Penetration testers report the team's approach to screenshots actually improves the company's security posture.

Proactive

Every month you hear about improvements to screenshots before anyone raises any issues with you.

"When I have a new feature request, I email support and get a response saying it's already possible - within minutes."

Rutger Tolenaar

Rutger Tolenaar, Founder ReviewTycoon

Website Screenshot Services

The Trusted Source for Website Screenshots

Screenshots are our business.

We've spent over a decade putting website screenshots first. Screenshots aren't a feature, side project or part of a suite of products for us. To us screenshots are everything.

A long-term profitable, 100% family owned business.

Latest Changes

Get improvements every month.

Security & Compliance

Enhance your security posture.

Accurate Screenshots

Screenshot automation like 250 web designers working for you at their Macs.

Automated Screenshots

Over 100 Options

A single API call to capture any URL or chunk of HTML just the way you want.

Website Screenshot API

High-Volume

Take one million screenshots before breakfast without breaking a sweat.

Specialist Support

UK based support team dedicated to solving all your screenshot problems.

Stripe Website Screenshot
Stripe Website Screenshot
Stripe Website Screenshot

Accurate Automated Screenshots

The Web's Screenshot Automation Platform

Automatically take screenshots like your designer on their Mac.

Repurpose web designs you have into images, PDFs, and more. Generate thousands of unique visual assets in minutes with no code. Retain data and insights hidden from view.

Take accurate screenshots for maximum credibility.

Say 'no more' to sloppy screenshots
Stripe Website Screenshot
import Urlbox from 'urlbox'
+const urlbox = Urlbox(
+  URLBOX_API_KEY,
+  URLBOX_API_SECRET
+)
+const renderLink = urlbox.generateRenderLink({
+  url: 'stripe.com',
+  width: 1440,
+  height: 840
+})
+return <img src={renderLink} />

Comprehensive Website Screenshot API

The Screenshot API You Can Depend On

A single API call to screenshot any URL or chunk of HTML.

Generate PNGs along with with fully hydrated HTML, markdown and metadata. Over 100 rendering options including custom JS. Wide format support including HTML, SVG, CSS & JS to image, PDF or video.

Get renders like they're straight out of your designer's Figma canvas.

Stop being haunted by headless browser hacks
onemillionscreenshots.com

High Volume Screenshots for Business

One Million Screenshots Before Breakfast

We start every month by taking over one million screenshots… just for fun.

Those screenshots maintain our fresh perspective on the web. You can zoom, pan and click to navigate the webs top homepages. Discover similar sites, see changes over time, and gather web data.

Meanwhile our customers continue to take tens of thousands of screenshots per minute.

Our servers don't break a sweat.

Check out OneMillionScreenshots.com

Support Example

Specialists Screenshot Support

Expert Support When You Need It

We're a small team of dedicated screenshot enthusiasts.

We relish every opportunity to solve your most challenging screenshot problems. You'll always get straight through to someone 100% focused on generating website screenshots.

You'll rarely have to wait for a whole business day – we usually get back to you within the hour!

Contact us now to experience it for yourself

FAQs

Common Questions

If anything's not clear we're here to help. Email via support@urlbox.com or use the chat widget in the bottom right corner. We'll try to get back to you within a few minutes and you'll always hear back from us within one working day.

No credit card required.

  • What counts as a Successful Render?

    Unlike other APIs we don't charge for requests that fail for any reason. This happens when a web page is unavailble or the combination of options sent to us is not valid. A Successful Render is one that results in an image being returned to you.

  • Do you cache screenshots?

  • Do requests to cached screenshots count against the monthly quota on the plan?

  • What happens if I go over my quota?

  • Do you send alerts when I am breaching or close to going over my monthly quota of screenshots?

Urlbox's support of emojis was a big signal that it could replace our own service. It was a simple replacement - a real success story for us.

Read the full story
Jānis Peisenieks

Jānis Peisenieks

HO Engineering

Using Urlbox, we've scaled our volume to over 5 times what we were. We’re confident that Urlbox will continue to produce great results as we grow.

Read the full story
Andy Croll

Andy Croll

CTO

Free Trial

Ready to start rendering?

Designers, law firms and infrastructure engineers trust Urlbox to accurately and securely convert HTML to images at scale. Experience it for yourself.

7 day free trial.No credit card required.

The Urlbox $100 10K Guarantee

Not happy with your first 10,000 screenshots?

We'll refund up to $100.

We and selected third parties collect personal information as specified in the privacy policy and use cookies or similar technologies for technical purposes and, with your consent, for functionality, measurement and “marketing (personalised ads)” as specified in the cookie policy.

You can freely give, deny, or withdraw your consent at any time by accessing the preferences panel. Denying consent may make related features unavailable.

Use the “Accept” button to consent. Use the “Reject” button to continue without accepting.

\ No newline at end of file diff --git a/Assets/javascript.png b/Assets/javascript.png new file mode 100644 index 0000000..d960603 Binary files /dev/null and b/Assets/javascript.png differ diff --git a/Assets/mobile.png b/Assets/mobile.png new file mode 100644 index 0000000..870621e Binary files /dev/null and b/Assets/mobile.png differ diff --git a/Assets/mp4.mp4 b/Assets/mp4.mp4 new file mode 100644 index 0000000..db6e8d7 Binary files /dev/null and b/Assets/mp4.mp4 differ diff --git a/Assets/pdf.pdf b/Assets/pdf.pdf new file mode 100644 index 0000000..b0f89ae Binary files /dev/null and b/Assets/pdf.pdf differ diff --git a/Example/Example.csproj b/Example/Example.csproj new file mode 100644 index 0000000..2e291d4 --- /dev/null +++ b/Example/Example.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/Example/Program.cs b/Example/Program.cs new file mode 100644 index 0000000..043e5c5 --- /dev/null +++ b/Example/Program.cs @@ -0,0 +1,95 @@ +using UrlboxSDK.DI.Extension; +using UrlboxSDK; +using UrlboxSDK.Response.Resource; +using UrlboxSDK.Exception; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Webhook.Resource; +using System.Text.Json; + +var builder = WebApplication.CreateBuilder(args); + +// Add Urlbox to the service container +builder.Services.AddUrlbox(config => +{ + // TODO Replace these with your keys from the dashboard: https://urlbox.com/dashboard/api + config.Key = "YOUR PUBLISHABLE API KEY HERE"; + config.Secret = "YOUR SECRET KEY HERE"; + // TODO optionally add this in to test out webhooks + config.WebhookSecret = "YOUR WEBHOOK SECRET KEY HERE"; + // if you need to use one of our specific subdomains + // config.BaseUrl = "https://api-eu.urlbox.com"; +}); + +var app = builder.Build(); + +// Urlbox gets injected by the service container +app.MapGet("/", async (HttpContext context, IUrlbox urlbox) => +{ + try + { + // Use the static .Options() method to choose your options + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com/docs") + // Play around with various options here + .Format(Format.Jpeg) + // Want to test out webhooks? see the POST endpoint below + // .WebhookUrl("https://YOUR NGROK FORWARDING ENDPOINT/webhook/urlbox") + .Build(); + + // Runs an async render, polls for success + // AsyncUrlboxResponse takeScreenshotResponse = await urlbox.TakeScreenshot(options); + + // Runs an async render, gives status response + AsyncUrlboxResponse renderAsyncResponse = await urlbox.RenderAsync(options); + + // Runs an sync render, waits for success before returning + // SyncUrlboxResponse renderSyncResponse = await urlbox.Render(options); + + return Results.Json(new + { + message = "Screenshot generated!", + // ResponseFromTakeScreenshot = takeScreenshotResponse, + ResponseFromRenderAsync = renderAsyncResponse, + // ResponseFromRenderSync = renderSyncResponse + }); + } + // Want to test how the exception looks? try this as the url in options: "https://notresolvableurlbox.com" + catch (UrlboxException ex) + { + Console.WriteLine(ex.Message); + Console.WriteLine(ex.RequestId); + return Results.Json(new { message = "Failed to generate screenshot, urlbox exception", error = ex.Message, reqId = ex.RequestId }); + } +}); + +/* + Webhook Example: + + 1. Make sure you've set your webhook secret in your Urlbox instantiation (line 16) + 2. Get ngrok (make an account and install on your computer) https://ngrok.com/ + 3. Run this project with `dotnet run`. + 4. Using ngrok expose the localhost port the .net server runs on EG for 5096 `ngrok http 5096` + 4. Take the ngrok forwarding address shown in CLI EG https://2c85-80-41-190-113.ngrok-free.app and + replace the above .WebhookUrl() arg with it in your options, including the /webhook/urlbox endpoint. + 5. Make a request to the GET endpoint "/" above with one of the render methods, and Urlbox will make a POST to your ngrok endpoint /webhook/urlbox. + EG: curl -i http://localhost:5096 +*/ +app.MapPost("/webhook/urlbox", async (HttpContext context, IUrlbox urlbox) => +{ + using StreamReader stream = new StreamReader(context.Request.Body); + + if (!context.Request.Headers.TryGetValue("x-urlbox-signature", out var headerValue)) + { + throw new Exception("Header 'x-urlbox-signature' not found."); + } + + UrlboxWebhookResponse verifiedResponse = urlbox.VerifyWebhookSignature(headerValue.ToString(), await stream.ReadToEndAsync()); + + string json = JsonSerializer.Serialize(verifiedResponse, new JsonSerializerOptions + { + WriteIndented = true + }); + + Console.WriteLine(json); +}); + +app.Run(); diff --git a/Example/Properties/launchSettings.json b/Example/Properties/launchSettings.json new file mode 100644 index 0000000..5ffbfda --- /dev/null +++ b/Example/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52163", + "sslPort": 44353 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5096", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7078;http://localhost:5096", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Example/appsettings.Development.json b/Example/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/Example/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Example/appsettings.json b/Example/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/Example/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Images/gh.png b/Images/gh.png new file mode 100644 index 0000000..b61761f Binary files /dev/null and b/Images/gh.png differ diff --git a/Images/projectKeys.png b/Images/projectKeys.png new file mode 100644 index 0000000..5c2d08f Binary files /dev/null and b/Images/projectKeys.png differ diff --git a/Images/urlbox-graphic.jpg b/Images/urlbox-graphic.jpg new file mode 100644 index 0000000..7b9b5af Binary files /dev/null and b/Images/urlbox-graphic.jpg differ diff --git a/Images/urlbox-png.png b/Images/urlbox-png.png new file mode 100644 index 0000000..40e636a Binary files /dev/null and b/Images/urlbox-png.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..71bc1ca --- /dev/null +++ b/README.md @@ -0,0 +1,964 @@ +[![image](Images/urlbox-graphic.jpg)](https://www.urlbox.com) + +*** + +# The Urlbox .NET SDK + +The Urlbox .NET SDK provides easy access to the [Urlbox API](https://urlbox.com/) from your application. + +Just initialise Urlbox and generate a screenshot of a URL or HTML in no time. + +Check out our [blog](https://urlbox.com/blog) for more insights on everything screenshots and what we're doing. + +> **Note:** At Urlbox we make `Renders`. Typically, when we refer to a render here or anywhere else, we are referring to the entire process as a whole of taking your options, performing our magic, and sending back a screenshot your way. + +#### Checkout [OneMillionScreenshots](https://onemillionscreenshots.com/) - A site that uses Urlbox to show over 1 million of the web's homepages! +*** + + +# Table Of Contents + + +* [Documentation](#documentation) +* [Requirements](#requirements) +* [Installation](#installation) +* [Usage](#usage) + * [Start here](#start-here) + * [Getting Started - `TakeScreenshot()`](#getting-started---takescreenshot) + * [Configuring Options](#configuring-options-) + * [Using the options builder](#using-the-options-builder) + * [Using the `new` keyword, setting during initialization](#using-the-new-keyword-setting-during-initialization) + * [What to do if an option isn't available in the builder](#what-to-do-if-an-option-isnt-available-in-the-builder) + * [Render Links - `GenerateRenderLink()`](#render-links---generaterenderlink) + * [Sync Requests - `Render()`](#sync-requests---render) + * [Async Requests - `RenderAsync()`](#async-requests---renderasync) + * [Polling](#polling) + * [Webhooks](#webhooks) + * [Handling Errors](#handling-errors) + * [Dependency Injection](#dependency-injection) +* [Utility Functions](#utility-functions) + * [`TakeScreenshot(options)`](#takescreenshotoptions) + * [`TakePdf(options)`](#takepdfoptions) + * [`TakeMp4(options)`](#takemp4options) + * [`TakeScreenshotWithMetadata(options)`](#takescreenshotwithmetadataoptions) + * [`ExtractMetadata(options)`](#extractmetadataoptions) + * [`ExtractMarkdown(options)`](#extractmarkdownoptions) + * [`ExtractHtml(options)`](#extracthtmloptions) + * [`ExtractMhtml(options)`](#extractmhtmloptions) + * [`DownloadAsBase64(options)`](#downloadasbase64options-) + * [`DownloadToFile(options, filePath)`](#downloadtofileoptions-filepath-) + * [`GeneratePNGUrl(options)`](#generatepngurloptions-) + * [`GenerateJPEGUrl(options)`](#generatejpegurloptions-) + * [`GeneratePDFUrl(options)`](#generatepdfurloptions-) +* [Popular Use Cases](#popular-use-cases) + * [Taking a Full Page Screenshot](#taking-a-full-page-screenshot) + * [Example MP4 (Full Page)](#example-mp4--full-page-) + * [Taking a Mobile view screenshot](#taking-a-mobile-view-screenshot) + * [Failing a request on 4XX-5XX](#failing-a-request-on-4xx-5xx) + * [Extracting Markdown/Metadata/HTML](#extracting-markdownmetadatahtml) + * [Generating a Screenshot Using a Selector](#generating-a-screenshot-using-a-selector) + * [Uploading to the cloud via an S3 bucket](#uploading-to-the-cloud-via-an-s3-bucket) + * [Using a Proxy](#using-a-proxy) + * [Using Webhooks](#using-webhooks) + * [1. Visit your Urlbox dashboard, and get your Webhook Secret.](#1-visit-your-urlbox-dashboard-and-get-your-webhook-secret) + * [2. Create your Urlbox instance in your C# project:](#2-create-your-urlbox-instance-in-your-c-project) + * [3. Make a request through any of our rendering methods.](#3-make-a-request-through-any-of-our-rendering-methods-) + * [4. Verify that the webhook comes from Urlbox](#4-verify-that-the-webhook-comes-from-urlbox) +* [API Reference](#api-reference) + * [Urlbox API Reference](#urlbox-api-reference) + * [Constructor](#constructor) + * [Static Methods](#static-methods) + * [Screenshot and File Generation Methods](#screenshot-and-file-generation-methods) + * [Download and File Handling Methods](#download-and-file-handling-methods) + * [URL Generation Methods](#url-generation-methods) + * [Status and Validation Methods](#status-and-validation-methods) + * [Response Classes](#response-classes) + * [`SyncUrlboxResponse`](#syncurlboxresponse) + * [`AsyncUrlboxResponse`](#asyncurlboxresponse) + * [`WebhookUrlboxResponse`](#webhookurlboxresponse) + * [`UrlboxException`](#urlboxexception) + * [`UrlboxMetadata`](#urlboxmetadata) + * [Available Enums](#available-enums) + * [Examples](#examples) + * [Example HTML](#example-html) + * [Example PDF](#example-pdf) + * [Example PDF Highlighting](#example-pdf-highlighting) + * [Example PNG injecting Javascript](#example-png-injecting-javascript) + * [Feedback](#feedback) + * [Changelog](#changelog) + + +*** + +# Documentation + +See [here](https://urlbox.com/docs/overview) for the Urlbox API Docs. It includes an exhaustive list of all the options you could pass to our API, including what they do and example usage. + +We also have guides for how to set up uploading your final render to your own [S3](https://urlbox.com/docs/guides/s3) bucket, or use [proxies](https://urlbox.com/docs/guides/proxies) for geo-specific sites. + +# Requirements + +To use this SDK, you need .NET Core 6.0 or later. + +# Installation + +Nuget: + +```bash +dotnet add package urlbox.sdk.dotnet +``` + +# Usage + +## Start here + +Visit [Urlbox](https://urlbox.com) to sign up for a trial. You'll need to visit your [projects](https://urlbox.com/dashboard/projects) page, and gather your Publishable Key, Secret Key, and Webhook Secret key (if you intend on using webhooks). + +## Getting Started - `TakeScreenshot()` + +If you want something super simple, initialize an instance of Urlbox with the above credentials, then call our `TakeScreenshot(options)` method with the options of your choosing: + +```CS +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UrlboxSDK; // This is our package + +namespace MyNamespace +{ + class Program + { + static async Task Main() + { + // We highly recommend storing your Urlbox API key and secret somewhere secure. + string apiKey = Environment.GetEnvironmentVariable("URLBOX_API_KEY"); + string apiSecret = Environment.GetEnvironmentVariable("URLBOX_API_SECRET"); + string webhookSecret = Environment.GetEnvironmentVariable("URLBOX_WEBHOOK_SECRET"); + + // Create an instance of Urlbox and the Urlbox options you'd like to use + Urlbox urlbox = Urlbox.FromCredentials(apiKey, apiSecret, webhookSecret); + // Use the builder pattern for fluent options + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + // Take a screenshot - The default format is PNG + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + + // This is the URL destination where you can find your finalized render. + Console.Writeline(response.RenderUrl); + } + } +} +``` + +If you use the above with your own keys, it will give you back an object with a `renderUrl`. Making a GET request to that renderUrl will give you back a PNG back like this: + +![](Images/urlbox-png.png) + +*** + +## Configuring Options + +Passing options are where the magic comes in. Options are simply extra inputs that we use to adapt the way we take the screenshot, or adapt any of the other steps involved in the rendering process. + +>**Note:** Almost all of our options are optional. However, you must at least provide a URL or some HTML in your options in order for us to know what we are rendering for you. + +You could, for example, change the way the request is made to your desired URL (like using a proxy server, passing in extra headers, an authorization token or some cookies), or change the way the page looks (like injecting Javascript, highlighting words, or making the background a tasteful fuchsia pink). + +There are a few ways to retrieve a screenshot from Urlbox, depending on how and when you need it. You could retrieve it as a [raw file](https://urlbox.com/docs/options#response_type) (using `UrlboxOptions.ResponseType(ResponseType.Binary)` ), or by default, as a JSON object with its size and stored location. + +There are a plethora of other options you can use. Checkout the [docs](https://urlbox.com/docs/overview) for more information. + +To initialise your urlbox options, we advise using the option builder. Start by calling the static method `Urlbox.Options()` with the URL or HTML you want to screenshot. + +The builder will validate your options on `.Build()`, and allow for a more readable/fluent interface in your code. + +### Using the options builder +```CS +using UrlboxSDK; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Response.Resource; + +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://urlbox.com" + ) + // Any Bool option sets to true when called with no arguments + .FullPage() + .Cookie("some=cookie", "someother=cookie") + .Gpu() + // Enumerables can be accessed/imported by their name: + .ResponseType(ResponseType.Json) + .BlockAds() + .HideCookieBanners() + .BlockUrls("https://ads.com", "https://trackers.com") + .Build(); + +AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + +Console.WriteLine(response.Status); +Console.WriteLine(response.RenderUrl); +``` + +You can alternatively set the Urlbox options with the `new` keyword. + +### Using the `new` keyword, setting during initialization + +We advise against using the `new` keyword. If you would like to anyway, here's an example: + +```CS +UrlboxOptions options = new(url: "https://urlbox.com") +{ + Format = Format.Pdf, + Gpu = true, + Retina = true, + DarkMode = true +}; + +// Or set them after init: +options.FullPage = true; + +AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); +``` + +### What to do if an option isn't available in the builder + +Our [latest](https://urlbox.com/docs/options#engine_version) engine is updated regularly, including new options which are released to better help you render screenshots. + +If you can't find an option within the builder, because our SDK isn't yet in sync with any latest changes, please do use our overloads for `render` and `renderAsync` which take an `IDictionary` instead of a `UrlboxOptions` type. + +Here's an example: + +```CS +IDictionary options = new Dictionary + { + { "click_accept", true }, + { "url", "https://urlbox.com" } + { "theOption", "YouCouldntFind" } + }; +SyncUrlboxResponse response = await urlbox.Render(options); + +Console.WriteLine(response); +``` +Please Bear in mind that this won't have the benefit of pre-validation. + +*** + +## Render Links - `GenerateRenderLink()` + +With Urlbox you can get a screenshot in a number of ways. It may seem a little complex at first, but each method has its purpose. + +Take a look at the [section in our docs](https://urlbox.com/docs/api/rest-api-vs-render-links#render-links) which explains the main benefits of using a render link over our `/sync` and `/async` methods. + +To get a render link, run the `GenerateRenderLink(options)` with your options. + +Once you have that render link, you're free to embed it anywhere you please. Make a GET request to that render link, and it will synchronously run a render, and return a screenshot. This is particularly handy for embedding into an `` tag. + +The method will, by default, sign the render link, for enhanced security. You can opt out of this by passing `urlbox.GenerateRenderLink(options, sign: false);` + +Here's an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://bbc.com" + ) + .Format(Format.Pdf) + .Build(); + +string renderLink = urlbox.GenerateRenderLink(options, sign: true); + +Console.WriteLine(renderLink); +``` + +## Sync Requests - `Render()` + +We have 2 other ways to get a screenshot from Urlbox, `render/sync` and `render/async`. + +Making a request to the [`/sync`](https://urlbox.com/docs/api#create-a-render-synchronously) endpoint means making a request that waits for your screenshot to be taken, and only then returns the response with your finished screenshot. You can achieve this by using the main `Render(options)` method. + +Here is an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://youtube.com" + ) + .Format(Format.Pdf) + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +If you haven't explicitly asked for a binary response in your options, you'll get a JSON 200 response like this: + +```JSON +{ + # Where the final screenshot is stored -- If you setup S3, it will be your bucket name / cdn host in the URL. + "renderUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.mp4", + # The size of the file in bytes + "size": 272154 +} +``` + +If you find that the kind of screenshot you are taking requires some time, and you don't want your network connection to be open for that long, the `/async` method may be better suited to your needs. Our `TakeScreenshot()` method already implements a polling mechanism using the `/async` endpoint and status checks, so you don't have to set one up yourself! + +*** + +## Async Requests - `RenderAsync()` + +Some renders can take some time to complete (think full page screenshots of infinitely scrolling sites, MP4 with retina level quality, or large full page PDF renders). + +If you anticipate your request being larger, then we would recommend using the [`/async`](https://urlbox.com/docs/api#create-a-render-asynchronously) endpoint by calling the `RenderAsync(options)` method or `TakeScreenshot(options)`. + +Here is an example of its usage: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://google.com" + ) + .Format(Format.Pdf) + .Build(); + +AsyncUrlboxResponse = await urlbox.RenderAsync(options); +``` + +This returns you: + +```JSON +{ + # When this is "succeeded", your render will be ready + "status": "created", + # This is your unique render id + "renderId": "fe7af5df-80e7-4b38-973a-005ebf06dabb", + # Make a GET to this to find out if your render is ready + "statusUrl": "https://api.urlbox.com/v1/render/fe7af5df-80e7-4b38-973a-005ebf06dabb" +} +``` + +You can find out _when_ your async render has been successfully made in two ways: + +### Polling + +You can [poll](https://en.wikipedia.org/wiki/Polling_(computer_science)) the `statusUrl` endpoint that comes back from the `/async` response via a GET request. The response from that status URL will include `"status": "succeeded"` when finished, as well as your final render URL. + +Use `TakeScreenshot()` to use our `/async` endpoint with a pre-built polling mechanism. The method will try for 60 seconds by default with an optional timeout. + +### Webhooks + +You can also use [webhooks](https://urlbox.com/docs/webhooks#using-webhooks) to tell you when your render is ready. Make a request to Urlbox, and we send the response as a POST request to an endpoint of your choosing. + +See the [Using Webhooks](#using-webhooks) section of these docs in for how to use webhooks with Urlbox in your application. + +## Handling Errors + +The SDK deserializes our API errors for you into an Exception class. + +The UrlboxException gives you some useful data. Here's an example: + +```CS +Urlbox urlbox = new(apiKey, apiSecret); + +UrlboxOptions options = Urlbox.Options( + url: "https://notaresolvableurlbox.com" + ) + .Build(); + +try +{ + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); +} +catch (UrlboxException exception) +{ + Console.WriteLine(exception.Message); // EG Invalid options, please check errors + Console.WriteLine(exception.Code); // EG InvalidOptions + Console.WriteLine(exception.Errors); // EG {"url":["error resolving URL - ENOTFOUND notresolvableurlbox.com"]} + Console.WriteLine(exception.RequestId); // EG 06u6e285-ahd3-45vc-ac8c-36b95e6c15b5 +} +``` + +The `Code` property will typically result in one of [these](https://urlbox.com/docs/api#error-codes). We're adding to this consistently to make you're error handling experience more streamlined. + +Got an unexpected 4XX or 5XX? You can ensure renders fail and don't count toward your render count for [non-2XX responses](#failing-a-request-on-4xx-5xx). + +## Dependency Injection + +We've set up an extension for DI. When you're configuring your DI you can run `services.AddUrlbox()` to define the Urlbox instance once. Here's a simple ASP.net app: + +```CS +using UrlboxSDK.DI.Extension; +using UrlboxSDK; +using UrlboxSDK.Response.Resource; + +var builder = WebApplication.CreateBuilder(args); + +// Add The Urlbox service to the service container +builder.Services.AddUrlbox(options => +{ + options.Key = "YOUR_API_KEY"; + options.Secret = "YOUR_SECRET"; + options.WebhookSecret = "YOUR-WEBHOOK-SECRET"; // Optional + options.BaseUrl = "https://api-eu.urlbox.com"; // Optional +}); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +// Urlbox gets injected from service container by reference to its interface +app.MapGet("/screenshot", async (HttpContext context, IUrlbox urlbox) => +{ + var options = Urlbox.Options(url: "https://example.com").Build(); + try + { + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + return Results.Json(new { message = "Screenshot generated!", response }); + } + catch (Exception ex) + { + return Results.Json(new { message = "Failed to generate screenshot", error = ex.Message }); + } +}); + +app.Run(); +``` + +*** + +# Utility Functions + +To make capturing and rendering screenshots even simpler, we’ve created several methods for common scenarios. Use these methods to quickly generate specific types of screenshots or files based on your needs: + +### `TakeScreenshot(options)` +Our simplest method to take a screenshot. Uses the `/async` Urlbox endpoint, and polls until the render is ready to reduce the time network requests stay open. + +### `TakePdf(options)` +Convert any URL or HTML into a PDF. + +### `TakeMp4(options)` +Turn any URL or HTML into an MP4 video. For a scrolling effect over the entire page, set `FullPage = true` to capture the full length of the content. + +### `TakeScreenshotWithMetadata(options)` +Takes a screenshot of any URL or HTML, bringing back a [UrlboxMetadata](#urlboxmetadata) object too with more information about the site. + +### `ExtractMetadata(options)` +Takes a screenshot of any URL or HTML, but extracts only the metadata from the render. Useful when you only need the `UrlboxMetadata` object from the render. + +### `ExtractMarkdown(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted markdown file as a string. + +### `ExtractHtml(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted HTML file as a string. + +### `ExtractMhtml(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted MHTML file as a string. + +### `DownloadAsBase64(options)` +Gets a render link, runs a GET to that link to render your screenshot, then downloads the screenshot file as a Base64 string. + +### `DownloadToFile(options, filePath)` +Gets a render link, runs a GET to that link to render your screenshot, then downloads and stores the screenshot to the given filePath. + +### `GeneratePNGUrl(options)` +Gets a render link for a screenshot in PNG format. + +### `GenerateJPEGUrl(options)` +Gets a render link for a screenshot in JPEG format. + +### `GeneratePDFUrl(options)` +Gets a render link for a screenshot in PDF format. + +# Popular Use Cases + +## Taking a Full Page Screenshot + +Want to take a screenshot of the full page from top to bottom? + +For almost all formats, this is available by simply running a request with the full page option + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .FullPage() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This will generate you a tall render, from the top to the bottom of the page. + +For video renders, there a bit more to it. To simply take a video of the website scrolling from top to bottom run a request like this: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com") + .Format(Format.Mp4) + .FullPage() + .VideoScroll() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` +This will render you a full page MP4 as the example below shows: + +### [Example MP4 (Full Page)](Assets/mp4.mp4) + +## Taking a Mobile view screenshot + +You may want to take a screenshot of a website/HTML as though it were being accessed from a mobile device. + +To achieve this you can simply change the width of the viewport to suit your needs. Here's an example for mobile: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com") + .Width(375) + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +Which should render you something like the below example: + +![](/Assets/mobile.png) + +## Failing a request on 4XX-5XX + +By default, Urlbox treats HTTP responses with status codes in the 400-599 range as successful renders, counting them toward your total render count. + +This feature enables you to capture screenshots of error responses when needed. If you prefer your render requests to fail when the response falls within this range, you can configure this behavior by passing `FailOn4xx()` and/or `FailOn5xx` as such: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .FailOn4xx() + .FailOn5xx() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This can save you renders over the month, particularly when tackling websites like tricky social media pages. + +If there is a failure, it will give you back a [UrlboxException](#urlboxexception). + +## Extracting Markdown/Metadata/HTML + +In addition to your main render format for your URL/HTML, you can additionally render and save the same screenshot as HTML, Markdown and/or Metadata in the same request. + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://urlbox.com" + ) + .Format(Format.Pdf) + .SaveMarkdown() // This saves the same URL/HTML's content as a markdown file + .SaveHtml() // This saves the same URL/HTML's content as its HTML + .SaveMetadata() // This extracts the metadata, saves it and sends it back in the response. + .Metadata() // This extracts the metadata from the URL/HTML, and sends it back in the response without saving it to the cloud. + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +The JSON response is deserialized and turned into the SyncUrlboxResponse. The JSON response would look like this: + +```JSON +{ + "renderUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.pdf", + "size": 1048576, + "htmlUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.html", + "metadataUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.json", + "markdownUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.md", + "metadata": { + "title": "Example Page", + "description": "This is an example of metadata information.", + "screenshot_date": "2024-11-06T12:34:56Z", + "file_size": 1048576, + "mime_type": "application/pdf" + } +} +``` + +When using the screenshot and file generation methods from our SDK like `TakeScreenshot()`, `Render()` or `RenderAsync()`, responses will all be turned into a readable class instance for you, being either the `SyncUrlboxResponse` or `AsyncUrlboxResponse` for 200's. + +When downloading metadata, you can opt to either save the metadata, or just return it in the JSON response as above. Our helper method `TakeScreenshotWithMetadata()` will not store the metadata so not produce a URL. It will instead only return the metadata object as above. + +## Generating a Screenshot Using a Selector + +There are times when you don't want to screenshot the entirety of a website. You may want to avoid manual cropping after taking your screenshot. You can take a screenshot of only the elements that you wish to using the selector. + +Here's an example of using the selector option with our `Render(options)` method: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://github.com") + .Selector(".octicon-mark-github") + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This will take the ID selector ".octicon-mark-github", and return a screenshot that looks like this: + +![](Images/gh.png) + +## Uploading to the cloud via an S3 bucket + +For a typical render, we do the storing for you. You can opt to save the final screenshot to your own cloud provider. + +We would _**highly**_ recommend you follow our S3 setup instructions. Setting up a cloud bucket can be tedious at the best of times, so [this](https://urlbox.com/docs/storage/configure-s3) part of our docs can help untangle the process. + +In theory, we support any S3 compatible provider, though we have tested the following providers: + +- BackBlaze B2 +- AWS S3 +- Cloudflare R2 +- Google Cloud Storage +- Digital Ocean Spaces + +If there's another cloud provider you would like to use, please try to reach out to us if you're struggling to get setup. + +We allow for public CDN hosts, private buckets and buckets with object locking enabled. + +Once you've set up your bucket, you can simply add `UrlboxOptions.UseS3()` to your options before making your request. + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .UseS3() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +## Using a Proxy + +Proxies can really help get past issues like rendering social media sites, or sites that track your origin. We have a great piece in our [docs](https://urlbox.com/docs/guides/proxies) to get you started. + +Simply pass in the proxy providers' details once you're set up, and we will make the request through that proxy. Here's an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .Proxy("http://brd-customer-hl_1a2b3c4d-zone-social_networks:ttpg162fe6e2@brd.superproxy.io:22225") + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +## Using Webhooks + +Webhooks are awesome. They save you time, money and headaches, and can quite equally cause just as many setting them up. Setting up a webhook with Urlbox has some optional steps, but we recommend you take them all for the most security. + +Please look at our example directory in the repo. + +### 1. Visit your Urlbox dashboard, and get your Webhook Secret. + +Go to your [projects](https://urlbox.com/dashboard/projects) page, select a project (you may only have one if you're just starting out with Urlbox), and copy the webhook secret key. + +### 2. Create your Urlbox instance in your C# project: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); +``` + +### 3. Make a request through any of our rendering methods. + +The most common use case for a webhook is when you need to use the `/async` endpoint to handle a larger render. + +If you're developing locally, we would recommend using a service like [ngrok](https://ngrok.com/), and setting your webhook URL in the options to that ngrok endpoint. + +After you've added the endpoint, for example at the endpoint `/webhooks/urlbox`, make a request to that endpoint like this: + +```CS +static async Task Main() +{ + Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + + UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .WebhookUrl("https://myapp.com/webhooks/urlbox") + .Build(); + + SyncUrlboxResponse response = await urlbox.Render(options); +} +``` + +### 4. Verify that the webhook comes from Urlbox + +Once you have made your request, you should see it come in as a POST request to the endpoint you've made in your app for the webhook. The body should look like this: + +```JSON +{ + "event": "render.succeeded", + "renderId": "19a59ab6-a5aa-4cde-86cb-d2b23302fd84", + "result": { + "renderUrl": "https://renders.urlbox.com/urlbox1/renders/6215a3df94d7588f7d910513/2024/1/11/19a59ab6-a5aa-4cde-86cb-d2b23302fd84.png", + "size": 34097 + }, + "meta": { + "startTime": "2024-01-11T17:49:18.593Z", + "endTime": "2024-01-11T17:49:21.103Z" + } +} +``` + +There will also be our header `X-Urlbox-Signature` that should have a value like this: `t={timestamp},sha256={token}`. + +Extract both the header and the content, and pass it into `Urlbox.VerifyWebhookSignature(header, content)`, which, if successful, will return you a [WebhookUrlboxResponse](#webhookurlboxresponse). + +Please see the `Example` project in this repo which should help you get started. + +--- + +# API Reference + +Below is a brief description of every publicly available method our SDK provides: + +## Urlbox API Reference + +### Constructor +- **`Urlbox(string key, string secret, string webhookSecret = null)`** + Initializes a new instance of the Urlbox class with the provided API credentials and optional webhook secret. + +--- + +### Static Methods +- **`static Urlbox FromCredentials(string apiKey, string apiSecret, string webhookSecret)`** + Creates a new instance of the Urlbox class using the specified API key, secret, and optional webhook secret. + +- **`static UrlboxOptionsBuilder Options(string? url = null, string? html = null)`** + Creates a new instance of the Urlbox options builder. Requires a URL or HTML in the constructor to get started. + +--- + +### Screenshot and File Generation Methods + +- **`Task TakeScreenshot(UrlboxOptions options);`** +- **`Task TakeScreenshot(UrlboxOptions options, int timeout);`** + Takes a screenshot asynchronously with a polling mechanism. Optional timeout to dictate when to stop polling. + +- **`Task TakePdf(UrlboxOptions options);`** + Asynchronously generates a PDF based on the specified options. + +- **`Task TakeMp4(UrlboxOptions options);`** + Generates an MP4 video asynchronously using the specified options. + +- **`Task TakeFullPageScreenshot(UrlboxOptions options);`** + Captures a full-page screenshot asynchronously with the given options. + +- **`Task TakeMobileScreenshot(UrlboxOptions options);`** + Takes a mobile-optimized screenshot asynchronously based on the specified options. + +- **`Task TakeScreenshotWithMetadata(UrlboxOptions options);`** + Asynchronously takes a screenshot and includes metadata in the response. + +- **`Task Render(UrlboxOptions options);`** +- **`Task Render(IDictionary options);`** + Sends a synchronous request to generate a render with the provided options, returning a direct response. + +- **`Task RenderAsync(UrlboxOptions options);`** +- **`Task RenderAsync(IDictionary options);`** + Sends an asynchronous render request, providing a status URL for polling until completion. + +--- + +### Download and File Handling Methods + +- **`Task DownloadAsBase64(UrlboxOptions options, string format = "png", bool sign = true);`** + Downloads a screenshot as a Base64-encoded string in the specified format. Optional format and whether to sign the render link. + +- **`Task DownloadAsBase64(string urlboxUrl);`** + Downloads the screenshot from the provided URL as a Base64-encoded string. + +- **`Task DownloadToFile(string urlboxUrl, string filename);`** + Downloads a screenshot from the URL and saves it to the specified file path. + +- **`Task DownloadToFile(UrlboxOptions options, string filename, string format = "png", bool sign = true);`** + Generates a screenshot based on options, then downloads and saves it as a file. Optional format and whether to sign the render link + +--- + +### URL Generation Methods + +- **`string GeneratePNGUrl(UrlboxOptions options, bool sign = true);`** + Generates a PNG URL based on the specified screenshot options. + +- **`string GenerateJPEGUrl(UrlboxOptions options, bool sign = true);`** + Creates a JPEG URL using the provided rendering options. + +- **`string GeneratePDFUrl(UrlboxOptions options, bool sign = true);`** + Generates a PDF URL for the specified screenshot options. + +- **`string GenerateRenderLink(UrlboxOptions options, string format = "png", bool sign = true);`** + Constructs an Urlbox URL for the specified format and options. + +- **`string GenerateSignedRenderLink(UrlboxOptions options, string format = "png");`** + Constructs an Urlbox URL for the specified format and options signed with the consumer's secret token. + +--- + +### Status and Validation Methods + +- **`Task GetStatus(string renderId);`** + Retrieves the current status of an asynchronous render request. + +- **`bool VerifyWebhookSignature(string header, string content);`** + Verifies that a webhook signature originates from Urlbox using the configured webhook secret. + + +### Response Classes + +When using the SDK, our deserializers will take the JSON response from any POST to the API and turn them into one of the following: + +#### `SyncUrlboxResponse` + +Properties: + +- **`RenderUrl`** - The URL to run a GET request to in order to access your final render. +- **`Size`** - The size of the render in bytes. +- **`HtmlUrl`** - The URL to run a GET request to in order to access your final render as HTML. +- **`MhtmlUrl`** - The URL to run a GET request to in order to access your final render as MHTML. +- **`MetadataUrl`** - The URL to run a GET request to in order to access your final render as Metadata (JSON). +- **`MarkdownUrl`** - The URL to run a GET request to in order to access your final render as Markdown. +- **`Metadata`** - The Metadata object describing the rendered website. + +#### `AsyncUrlboxResponse` + +Properties: + +- **`Status`** - One of `waiting`, `active`, `failed`, `delayed`, `succeeded`. +- **`RenderId`** - The unique ID of the render request. +- **`StatusUrl`** - The URL to run a GET request to in order to find out if the render completed. +- **`Size`** - The size of the render in bytes. +- **`RenderUrl`** - The URL to run a GET request to in order to access your final render. +- **`HtmlUrl`** - The URL to run a GET request to in order to access your final render as HTML. +- **`MhtmlUrl`** - The URL to run a GET request to in order to access your final render as MHTML. +- **`MetadataUrl`** - The URL to run a GET request to in order to access your final render as Metadata (JSON). +- **`MarkdownUrl`** - The URL to run a GET request to in order to access your final render as Markdown. +- **`Metadata`** - The Metadata object describing the rendered website. + +#### `WebhookUrlboxResponse` + +Properties: + +- **`Event`** - The event that happened to the render EG "render.succeeded" +- **`RenderId`** - The unique ID of the render request. +- **`Error`** - The error from Urlbox, showing the code, message and any errors +- **`Result`** - An instance of the SyncUrlboxResponse +- **`Meta`** - Includes the start and end times for the render + +#### `UrlboxException` + +Properties: + +- **`RequestId`** - The unique ID of the render request. +- **`Code`** - The error code for the request. See a list [here](https://urlbox.com/docs/api#error-codes). +- **`Errors`** - A more detailed list of errors that occurred in the request. + +#### `UrlboxMetadata` + +Properties: + +- **`UrlRequested`** - The original URL requested for rendering. +- **`UrlResolved`** - The final resolved URL after any redirects. +- **`Url`** - The canonical URL of the rendered page. +- **`Author`** - The author of the content, if available. +- **`Date`** - The publication date of the content, if available. +- **`Description`** - The meta description of the page. +- **`Image`** - The primary image of the page, if available. +- **`Logo`** - The logo associated with the page or publisher. +- **`Publisher`** - The name of the publisher of the content. +- **`Title`** - The title of the page. +- **`OgTitle`** - The Open Graph title of the page. +- **`OgImages`** - A list of Open Graph images found on the page. +- **`OgDescription`** - The Open Graph description of the page. +- **`OgUrl`** - The Open Graph URL of the page. +- **`OgType`** - The Open Graph type of the page (e.g., article, website). +- **`OgSiteName`** - The Open Graph site name of the page. +- **`OgLocale`** - The locale specified by Open Graph metadata. +- **`Charset`** - The character encoding used by the page. +- **`TwitterCard`** - The Twitter card type for the page. +- **`TwitterSite`** - The Twitter site associated with the page. +- **`TwitterCreator`** - The Twitter creator associated with the page. + +### Available Enums + +There are a number of options which are one of a select few. We have made enums for these, which can be accessed directly from the UrlboxOptions namespace: + +ColorProfile - one of `Colorspingamma24`, `Default`, `Dp3`, `Hdr10`, `Rec2020`, `Scrgblinear`, `Srgb` + +EngineVersion - one of `Latest`, `Lts`, `Stable` + +Format - one of `Avif`, `Html`, `Jpeg`, `Jpg`, `Md`, `Mhtml`, `Mp4`, `Pdf`, `Png`, `Svg`, `Webm`, `Webp` + +FullPageMode - one of `Native`, `Stitch` + +ImgFit - one of `Contain`, `Cover`, `Fill`, `Inside`, `Outside` + +ImgPosition - one of `Attention`, `Bottom`, `Center`, `Centre`, `East`, `Entropy`, `Left`, `LeftBottom`, `LeftTop`, `North`, `Northeast`, `Northwest`, `Right`, `RightBottom`, `RightTop`, `South`, `Southeast`, `Southwest`, `Top`, `West` + +Media - one of `Print`, `Screen` + +PdfMargin - one of `Default`, `Minimum`, `None` + +PdfOrientation - one of `Landscape`, `Portait` + +PdfPageSize - one of `A0`, `A1`, `A2`, `A3`, `A4`, `A5`, `A6`, `Ledger`, `Legal`, `Letter`, `PdfPageSizeA0`, `PdfPageSizeA1`, `PdfPageSizeA2`, `PdfPageSizeA3`, `PdfPageSizeA4`, `PdfPageSizeA5`, `PdfPageSizeA6`, `PdfPageSizeLedger`, `PdfPageSizeLegal`, `PdfPageSizeLetter`, `PdfPageSizeTabloid`, `Tabloid` + +ResponseType - one of `Base64`, `Binary`, `Json`, `Jsondebug`, `None` + +S3Storageclass - one of `DeepArchive`, `Glacier`, `IntelligentTiering`, `OnezoneIa`, `Outposts`, `ReducedRedundancy`, `S3StorageclassDeepArchive`, `S3StorageclassGlacier`, `S3StorageclassIntelligentTiering`, `S3StorageclassOnezoneIa`, `S3StorageclassOutposts`, `S3StorageclassReducedRedundancy`, `S3StorageclassStandard`, `S3StorageclassStandardIa`, `Standard`, `StandardIa` + +VideoCodec - one of `H264`, `Vp8`, `Vp9` + +VideoEase - one of `BackIn`, `BackInout`, `BackOut`, `BounceIn`, `BounceInout`, `BounceOut`, `CircularIn`, `CircularInout`, `CircularOut`, `CubicIn`, `CubicInout`, `CubicOut`, `ElasticIn`, `ElasticInout`, `ElasticOut`, `ExponentialIn`, `ExponentialInout`, `ExponentialOut`, `LinearNone`, `QuadraticIn`, `QuadraticInout`, `QuadraticOut`, `QuarticIn`, `QuarticInout`, `QuarticOut`, `QuinticIn`, `QuinticInout`, `QuinticOut`, `SinusoidalIn`, `SinusoidalInout`, `SinusoidalOut` + +VideoMethod - one of `Extension`, `Psr`, `Screencast` + +VideoPreset - one of `Fast`, `Faster`, `Medium`, `Slow`, `Slower`, `Superfast`, `Ultrafast`, `Veryfast`, `Veryslow` + +WaitUntil - one of `Domloaded`, `Loaded`, `Mostrequestsfinished`, `Requestsfinished` + +## Examples + +### [Example HTML](Assets/html.html) +### [Example PDF](Assets/pdf.pdf) +### [Example PDF Highlighting](Assets/highlight.pdf) +### [Example PNG injecting Javascript](Assets/javascript.png) + +## Feedback + +We hope that the above has given you enough of an understanding to suit your use case. + +If you are still struggling, spot a bug, or have any suggestions, feel free to contact us at: `support@urlbox.com` or use our chat function on [our website](https://urlbox.com/). + +Get rendering! + +## Changelog + +- 2.0.0 - Major overhaul - **Non-backward compatible changes included.** + - Introduced fluent options builder with input validation. + - Introduced options as a typed class. + - Introduced webhook validation logic. + - Upgraded test suite. + - Created interfaces for DI. + - Introduced post sync and async methods. + - Introduced helper methods for common use cases. + - Overhauled readme including an API reference. + - Introduced logic and classes for side renders (save_html etc). + - Introduced classes for different response types from urlbox api. + - Added overhauls for render/renderAsync which take IDictionary for future proofing. + - Overhauls readme. + +Methods in previous versions of this SDK that would accept a Dictionary now take a standardised `UrlboxOptions` type. + +- 1.0.2 - Further Updates to readme. + +- 1.0.1 - Update Readme to replace instances of .io with .com. + +- 1.0.0 - First release! diff --git a/Urlbox-Dotnet.sln b/Urlbox-Dotnet.sln index 0b4aab1..971716a 100644 --- a/Urlbox-Dotnet.sln +++ b/Urlbox-Dotnet.sln @@ -1,11 +1,11 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 2012 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Urlbox", "Urlbox\Urlbox.csproj", "{EB9CA65B-8F85-4CF9-913A-A8C75E72A926}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Urlbox", "UrlboxSDK\UrlboxSDK.csproj", "{EB9CA65B-8F85-4CF9-913A-A8C75E72A926}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Urlbox.MsTest", "Urlbox.MsTest\Urlbox.MsTest.csproj", "{B9E8D269-174F-42C1-9569-FCA4CC8C05E1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Urlbox.MsTest", "UrlboxSDK.MsTest\UrlboxSDK.MsTest.csproj", "{B9E8D269-174F-42C1-9569-FCA4CC8C05E1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Urlbox.xUnit", "Urlbox.xUnit\Urlbox.xUnit.csproj", "{B547F383-6112-4C6D-9651-1FA35D5FF70F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example", "Example\Example.csproj", "{E7F7CC5B-F8B1-4D9E-B1DA-CABEB394AD85}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -25,5 +25,9 @@ Global {B547F383-6112-4C6D-9651-1FA35D5FF70F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B547F383-6112-4C6D-9651-1FA35D5FF70F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B547F383-6112-4C6D-9651-1FA35D5FF70F}.Release|Any CPU.Build.0 = Release|Any CPU + {E7F7CC5B-F8B1-4D9E-B1DA-CABEB394AD85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7F7CC5B-F8B1-4D9E-B1DA-CABEB394AD85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7F7CC5B-F8B1-4D9E-B1DA-CABEB394AD85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7F7CC5B-F8B1-4D9E-B1DA-CABEB394AD85}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Urlbox.MsTest/Urlbox.MsTest.csproj b/Urlbox.MsTest/Urlbox.MsTest.csproj deleted file mode 100644 index 7cb1a93..0000000 --- a/Urlbox.MsTest/Urlbox.MsTest.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - netcoreapp2.0 - - false - - - - - - - - - - - - diff --git a/Urlbox.MsTest/UrlboxTest.cs b/Urlbox.MsTest/UrlboxTest.cs deleted file mode 100644 index 1b619cb..0000000 --- a/Urlbox.MsTest/UrlboxTest.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Diagnostics; -using System.Dynamic; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Screenshots; - -[TestClass] -public class UrlTests -{ - private Urlbox urlbox; - - [TestInitialize] - public void TestInitialize() - { - urlbox = new Urlbox("MY_API_KEY", "secret"); - } - - //[TestMethod] - //public void WithOptions() - //{ - // dynamic options = new ExpandoObject(); - // options.url = "bbc.co.uk"; - // options.Width = 1280; - // options.Thumb_Width = 500; - // options.Full_Page = true; - - // var output = urlbox.GenerateUrlboxUrl(options); - // Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/d6b5068716c19ba4556648ad9df047d5847cda0c/png?url=bbc.co.uk&width=1280&thumb_width=500&full_page=true", - // output, "Not OK"); - //} - - [TestMethod] - public void WithUrlEncodedOptions() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - options.Width = 1280; - options.Thumb_Width = 500; - options.FullPage = true; - options.UserAgent = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/9c675714240421b50a9f76892d702cb0a5376ccf/png?url=bbc.co.uk&width=1280&thumb_width=500&full_page=true&user_agent=Mozilla%2F5.0%20%28Windows%20NT%206.1%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F41.0.2228.0%20Safari%2F537.36", - output, "Not OK"); - } - - [TestMethod] - public void UrlNeedsEncoding() - { - dynamic options = new ExpandoObject(); - options.url = "https://www.hatchtank.io/markup/index.html?url2png=true&board=demo_1645_1430"; - var output = urlbox.GenerateUrlboxUrl(options); - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/4b8ac501f3aaccbea2081a7105302593174ebc23/png?url=https%3A%2F%2Fwww.hatchtank.io%2Fmarkup%2Findex.html%3Furl2png%3Dtrue%26board%3Ddemo_1645_1430", - output, "Not OK"); - } - - [TestMethod] - public void WithUserAgent() - { - dynamic options = new ExpandoObject(); - options.Url = "https://bbc.co.uk"; - options.User_Agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/c2708392a4d881b4816e61b3ed4d89ae4f2c4a57/png?url=https%3A%2F%2Fbbc.co.uk&user_agent=Mozilla%2F5.0%20%28Macintosh%3B%20Intel%20Mac%20OS%20X%2010_12_6%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F62.0.3202.94%20Safari%2F537.36", output); - } - - [TestMethod] - public void IgnoreEmptyValuesAndFormat() - { - dynamic options = new ExpandoObject(); - options.Url = "https://bbc.com"; - options.Full_Page = false; - options.ThumbWidth = ""; - options.Delay = null; - options.Format = "pdf"; - options.Selector = ""; - options.WaitFor = ""; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/ffb3bf33fe1cc481c33f78de7762134662b63dad/png?url=https%3A%2F%2Fbbc.com&full_page=false", - output, "Not OK"); - } - - [TestMethod] - public void FormatWorks() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - var output = urlbox.GenerateUrlboxUrl(options, "jpeg"); - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/75c9016e7f98f90f5eabfd348f3091f7bf625153/jpeg?url=bbc.co.uk", output, "Not OK!"); - } - - [TestMethod] - public void WithoutUrl() - { - dynamic options = new ExpandoObject(); - //options.Width = 500; - options.full_page = true; - var output = urlbox.GenerateUrlboxUrl(options); - Assert.IsTrue(true); - } - - [TestMethod] - public void SimpleURL() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - var output = urlbox.GenerateUrlboxUrl(options); - - Assert.AreEqual("https://api.urlbox.io/v1/MY_API_KEY/75c9016e7f98f90f5eabfd348f3091f7bf625153/png?url=bbc.co.uk", - output, "Not OK"); - } -} - -[TestClass] -public class DownloadTests -{ - - private Urlbox urlbox; - - [TestInitialize] - public void TestInitialize() - { - urlbox = new Urlbox("MY_API_KEY", "secret"); - } - - [TestMethod] - public async Task TestDownloadToFile() - { - //Urlbox s = new Urlbox("MY_API_KEY", "secret"); - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/5ee277f206869517d00cf1951f30d48ef9c64bfe/png?url=google.com"; - var result = await urlbox.DownloadToFile(urlboxUrl, "result.png"); - //Debug.WriteLine(result, "RESULT - Download"); - Assert.IsTrue(true); - } - - [TestMethod] - public async Task TestDownloadBase64() - { - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ace/jpeg?url=bbc.co.uk"; - var base64result = await urlbox.DownloadAsBase64(urlboxUrl); - //Debug.WriteLine(base64result, "RESULT - BASE64"); - Assert.IsTrue(true); - } - - [TestMethod] - public async Task TestDownloadFail() - { - //Urlbox s = new Urlbox("MY_API_KEY", "secret"); - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ac/jpeg?url=bbc.co.uk"; - var base64result = await urlbox.DownloadAsBase64(urlboxUrl); - Debug.WriteLine(base64result, "RESULT - BASE64"); - Assert.IsTrue(true); - } -} diff --git a/Urlbox.xUnit/UnitTest1.cs b/Urlbox.xUnit/UnitTest1.cs deleted file mode 100644 index 812e959..0000000 --- a/Urlbox.xUnit/UnitTest1.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Dynamic; -using Xunit; -using Screenshots; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace Screenshots.xUnit -{ - public class UnitTest1 - { - - private Urlbox urlbox = new Urlbox("MY_API_KEY", "secret"); - - //[Fact] - //public void WithoutUrl() - //{ - // dynamic options = new ExpandoObject(); - // //options.Width = 500; - // options.full_page = true; - // var output = urlbox.GenerateUrlboxUrl(options); - // Assert.True(true); - //} - - //[Fact] - //public void SimpleURL() - //{ - // dynamic options = new ExpandoObject(); - // options.url = "bbc.co.uk"; - // var output = urlbox.GenerateUrlboxUrl(options); - - // Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/75c9016e7f98f90f5eabfd348f3091f7bf625153/png?url=bbc.co.uk", - // output); - //} - - [Fact] - public void WithOptions() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - options.Width = 1280; - options.Thumb_Width = 500; - options.Full_Page = true; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/d6b5068716c19ba4556648ad9df047d5847cda0c/png?url=bbc.co.uk&width=1280&thumb_width=500&full_page=true", - output); - } - [Fact] - public void WithUrlEncodedOptions() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - options.Width = 1280; - options.Thumb_Width = 500; - options.FullPage = true; - options.UserAgent = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/9c675714240421b50a9f76892d702cb0a5376ccf/png?url=bbc.co.uk&width=1280&thumb_width=500&full_page=true&user_agent=Mozilla%2F5.0%20%28Windows%20NT%206.1%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F41.0.2228.0%20Safari%2F537.36", - output); - } - - [Fact] - public void UrlNeedsEncoding() - { - dynamic options = new ExpandoObject(); - options.url = "https://www.hatchtank.io/markup/index.html?url2png=true&board=demo_1645_1430"; - var output = urlbox.GenerateUrlboxUrl(options); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/4b8ac501f3aaccbea2081a7105302593174ebc23/png?url=https%3A%2F%2Fwww.hatchtank.io%2Fmarkup%2Findex.html%3Furl2png%3Dtrue%26board%3Ddemo_1645_1430", - output); - } - - [Fact] - public void WithUserAgent() - { - dynamic options = new ExpandoObject(); - options.Url = "https://bbc.co.uk"; - options.User_Agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/c2708392a4d881b4816e61b3ed4d89ae4f2c4a57/png?url=https%3A%2F%2Fbbc.co.uk&user_agent=Mozilla%2F5.0%20%28Macintosh%3B%20Intel%20Mac%20OS%20X%2010_12_6%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F62.0.3202.94%20Safari%2F537.36", - output); - } - - [Fact] - public void IgnoreEmptyValuesAndFormat() - { - dynamic options = new ExpandoObject(); - options.Url = "https://bbc.com"; - options.Full_Page = false; - options.ThumbWidth = ""; - options.Delay = null; - options.Format = "pdf"; - options.Selector = ""; - options.WaitFor = ""; - - var output = urlbox.GenerateUrlboxUrl(options); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/ffb3bf33fe1cc481c33f78de7762134662b63dad/png?url=https%3A%2F%2Fbbc.com&full_page=false", - output); - } - - [Fact] - public void FormatWorks() - { - dynamic options = new ExpandoObject(); - options.url = "bbc.co.uk"; - var output = urlbox.GenerateUrlboxUrl(options, "jpeg"); - Assert.Equal("https://api.urlbox.io/v1/MY_API_KEY/75c9016e7f98f90f5eabfd348f3091f7bf625153/jpeg?url=bbc.co.uk", - output); - } - } - - public class DownloadTests - { - - private Urlbox urlbox = new Urlbox("MY_API_KEY", "secret"); - - [Fact] - public async Task TestDownloadToFile() - { - //Urlbox s = new Urlbox("MY_API_KEY", "secret"); - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/5ee277f206869517d00cf1951f30d48ef9c64bfe/png?url=google.com"; - var result = await urlbox.DownloadToFile(urlboxUrl, "result.png"); - //Debug.WriteLine(result, "RESULT - Download"); - Assert.True(true); - } - - [Fact] - public async Task TestDownloadBase64() - { - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ace/jpeg?url=bbc.co.uk"; - var base64result = await urlbox.DownloadAsBase64(urlboxUrl); - //Debug.WriteLine(base64result, "RESULT - BASE64"); - Assert.True(true); - } - - [Fact] - public async Task TestDownloadFail() - { - //Urlbox s = new Urlbox("MY_API_KEY", "secret"); - var urlboxUrl = "https://api.urlbox.io/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ac/jpeg?url=bbc.co.uk"; - var base64result = await urlbox.DownloadAsBase64(urlboxUrl); - Debug.WriteLine(base64result, "RESULT - BASE64"); - Assert.True(true); - } - } -} diff --git a/Urlbox.xUnit/Urlbox.xUnit.csproj b/Urlbox.xUnit/Urlbox.xUnit.csproj deleted file mode 100644 index 922e526..0000000 --- a/Urlbox.xUnit/Urlbox.xUnit.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - netcoreapp2.0 - - false - - - - - - - - - - - - diff --git a/Urlbox/Urlbox/UrlGenerator.cs b/Urlbox/Urlbox/UrlGenerator.cs deleted file mode 100644 index c0f01ea..0000000 --- a/Urlbox/Urlbox/UrlGenerator.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; -using System.Text; -using System.Linq; - -namespace Screenshots -{ - public class UrlGenerator - { - private String key; - private String secret; - - public UrlGenerator(string key, string secret){ - this.key = key; - this.secret = secret; - } - - - private string ToQueryString(IDictionary options) - { - var result = options - .ToList() - .Where(pair => !pair.Key.ToLower().Equals("format")) // skip format option if present - .Select(pair => new KeyValuePair(pair.Key, ConvertToString(pair.Value))) // convert values to string - .Where(pair => !String.IsNullOrEmpty(pair.Value)) // skip empty/null values - .Select(pair => string.Format("{0}={1}", FormatKeyName(pair.Key), Uri.EscapeDataString(pair.Value))) - .ToArray(); - return String.Join("&", result); - } - - private static string FormatKeyName(string input) - { - return string.Concat(input.Select((x, i) => i > 0 && char.IsUpper(x) && !input[i-1].Equals('_') ? "_" + x.ToString() : x.ToString())).ToLower(); - - } - - private static string ConvertToString(object value) - { - - var result = Convert.ToString(value); - if (result.Equals("False") || result.Equals("True")) - { - result = result.ToLower(); - } - return result; - } - - - public string GenerateUrlboxUrl(IDictionary options, string format = "png") - { - var qs = ToQueryString(options); - return string.Format("https://api.urlbox.io/v1/{0}/{1}/{2}?{3}", - this.key, - generateToken(qs), - format, - qs - ); - } - - private string generateToken(string queryString) - { - HMACSHA1 sha = new HMACSHA1(Encoding.UTF8.GetBytes(this.secret)); - MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(queryString)); - return sha.ComputeHash(stream).Aggregate("", (current, next) => current + String.Format("{0:x2}", next), current => current); - } - } -} diff --git a/Urlbox/Urlbox/Urlbox.cs b/Urlbox/Urlbox/Urlbox.cs deleted file mode 100644 index 5d9c1d5..0000000 --- a/Urlbox/Urlbox/Urlbox.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; -using System.IO; -using System.Diagnostics; -using System.Linq; -using System.Collections.Generic; -using System.Text; - - -namespace Screenshots -{ - public class Urlbox - { - private String key; - private String secret; - private UrlGenerator urlGenerator; - - public Urlbox(string key, string secret) - { - if (String.IsNullOrEmpty(key)) - { - throw new ArgumentException("Please provide your Urlbox.io API Key"); - } - if (String.IsNullOrEmpty(secret)) - { - throw new ArgumentException("Please provide your Urlbox.io API Secret"); - } - this.key = key; - this.secret = secret; - this.urlGenerator = new UrlGenerator(key, secret); - } - - public async Task DownloadAsBase64(IDictionary options, string format = "png"){ - var urlboxUrl = this.GenerateUrlboxUrl(options, format); - return await DownloadAsBase64(urlboxUrl); - } - - public async Task DownloadAsBase64(string urlboxUrl) - { - Func> onSuccess = async (result) => - { - var bytes = await result.Content.ReadAsByteArrayAsync(); - var contentType = result.Content.Headers.ToDictionary(l => l.Key, k => k.Value)["Content-Type"]; - var base64 = contentType.First() + ";base64," + Convert.ToBase64String(bytes); - return base64; - }; - return await this.Download(urlboxUrl, onSuccess); - } - - public async Task DownloadToFile(IDictionary options, string filename, string format = "png"){ - var urlboxUrl = GenerateUrlboxUrl(options, format); - return await DownloadToFile(urlboxUrl, filename); - } - - public async Task DownloadToFile(string urlboxUrl, string filename) - { - Func> onSuccess = async (result) => - { - using ( - Stream contentStream = await result.Content.ReadAsStreamAsync(), - stream = new FileStream(filename, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await contentStream.CopyToAsync(stream); - } - return await result.Content.ReadAsStringAsync(); - }; - return await Download(urlboxUrl, onSuccess); - } - - private async Task Download(string urlboxUrl, Func> onSuccess) - { - using (var client = new HttpClient()) - { - using (var result = await client.GetAsync(urlboxUrl).ConfigureAwait(false)) - { - if (result.IsSuccessStatusCode) - { - Debug.WriteLine(result, "SUCCESS!"); - return await onSuccess(result); - } - else - { - Debug.WriteLine(result, "FAIL"); - return "FAIL"; - } - } - } - } - - public string GeneratePNGUrl(IDictionary options) - { - return GenerateUrlboxUrl(options, "png"); - } - - public string GenerateJPEGUrl(IDictionary options) - { - return GenerateUrlboxUrl(options, "jpg"); - } - - public string GeneratePDFUrl(IDictionary options) - { - return GenerateUrlboxUrl(options, "pdf"); - } - - public string GenerateUrlboxUrl(IDictionary options, string format = "png") - { - return urlGenerator.GenerateUrlboxUrl(options, format); - } - } -} \ No newline at end of file diff --git a/Urlbox/Urlbox/Urlbox.csproj b/Urlbox/Urlbox/Urlbox.csproj deleted file mode 100644 index 509d9cb..0000000 --- a/Urlbox/Urlbox/Urlbox.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - netcoreapp2.0 - - - diff --git a/UrlboxSDK.MsTest/DI/Extension/UrlboxExtensionTest.cs b/UrlboxSDK.MsTest/DI/Extension/UrlboxExtensionTest.cs new file mode 100644 index 0000000..508f450 --- /dev/null +++ b/UrlboxSDK.MsTest/DI/Extension/UrlboxExtensionTest.cs @@ -0,0 +1,169 @@ +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Config.Resource; +using UrlboxSDK.DI.Extension; + +namespace UrlboxSDK.MsTest.DI.Extension +{ + [TestClass] + public class UrlboxExtensionTest + { + /// + /// Tests registering the UrlboxConfig obj in Service Container as an IOptions + /// + [TestMethod] + public void AddUrlbox_RegistersUrlboxConfig() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + options.WebhookSecret = "test-webhook"; + options.BaseUrl = "https://test-urlbox.com"; + }); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + UrlboxConfig options = serviceProvider.GetRequiredService>().Value; + Assert.AreEqual(apiKey, options.Key); + Assert.AreEqual(apiSecret, options.Secret); + Assert.AreEqual("test-webhook", options.WebhookSecret); + Assert.AreEqual("https://test-urlbox.com", options.BaseUrl); + } + + /// + /// Tests that AddUrlbox adds an instance of Urlbox with the default lifetime + /// + [TestMethod] + public void AddUrlbox_RegistersUrlboxService() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + options.WebhookSecret = "test-webhook"; + options.BaseUrl = "https://test-urlbox.com"; + }); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + ServiceDescriptor descriptor = services.FirstOrDefault(serviceDescriptor => serviceDescriptor.ServiceType == typeof(IUrlbox)); + Assert.IsNotNull(descriptor, "IUrlbox service is not registered."); + Assert.AreEqual(ServiceLifetime.Singleton, descriptor.Lifetime, "The registered lifetime is not the default Singleton."); + + IUrlbox urlboxService = serviceProvider.GetRequiredService(); + Assert.IsNotNull(urlboxService); + Assert.IsInstanceOfType(urlboxService, typeof(Urlbox)); + } + + [TestMethod] + public void AddUrlbox_CreatesUrlboxInstanceWithCorrectConfig() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + options.WebhookSecret = "test-webhook"; + options.BaseUrl = "https://test-urlbox.com"; + }); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IUrlbox urlbox = (Urlbox)serviceProvider.GetRequiredService(); + + string jpgUrl = urlbox.GenerateJPEGUrl(Urlbox.Options(url: "https://urlbox.com").Build()); + + Assert.IsTrue(jpgUrl.Contains("test-key")); + } + + [TestMethod] + public void AddUrlbox_RegistersAsSingleton() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + }, ServiceLifetime.Singleton); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + ServiceDescriptor descriptor = services.FirstOrDefault(serviceDescriptor => serviceDescriptor.ServiceType == typeof(IUrlbox)); + Assert.IsNotNull(descriptor, "IUrlbox service is not registered."); + Assert.AreEqual(ServiceLifetime.Singleton, descriptor.Lifetime, "The registered lifetime is not Singleton."); + + IUrlbox instance1 = serviceProvider.GetRequiredService(); + IUrlbox instance2 = serviceProvider.GetRequiredService(); + Assert.AreSame(instance1, instance2); // Singleton instances should be the same + } + + [TestMethod] + public void AddUrlbox_RegistersAsScoped() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + }, ServiceLifetime.Scoped); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + ServiceDescriptor descriptor = services.FirstOrDefault(serviceDescriptor => serviceDescriptor.ServiceType == typeof(IUrlbox)); + Assert.IsNotNull(descriptor, "IUrlbox service is not registered."); + Assert.AreEqual(ServiceLifetime.Scoped, descriptor.Lifetime, "The registered lifetime is not scoped."); + + using (IServiceScope scope1 = serviceProvider.CreateScope()) + { + IUrlbox instance1 = scope1.ServiceProvider.GetRequiredService(); + IUrlbox instance2 = scope1.ServiceProvider.GetRequiredService(); + Assert.AreSame(instance1, instance2); // Scoped instances within the same scope should be the same + } + + using (IServiceScope scope2 = serviceProvider.CreateScope()) + { + IUrlbox instance3 = scope2.ServiceProvider.GetRequiredService(); + Assert.AreNotSame(instance3, serviceProvider.GetRequiredService()); // Scoped instances across scopes should differ + } + } + + [TestMethod] + public void AddUrlbox_RegistersAsTransient() + { + ServiceCollection services = new(); + string apiKey = "test-key"; + string apiSecret = "test-secret"; + + services.AddUrlbox(options => + { + options.Key = apiKey; + options.Secret = apiSecret; + }, ServiceLifetime.Transient); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + ServiceDescriptor descriptor = services.FirstOrDefault(serviceDescriptor => serviceDescriptor.ServiceType == typeof(IUrlbox)); + Assert.IsNotNull(descriptor, "IUrlbox service is not registered."); + Assert.AreEqual(ServiceLifetime.Transient, descriptor.Lifetime, "The registered lifetime is not transient."); + + + IUrlbox instance1 = serviceProvider.GetRequiredService(); + IUrlbox instance2 = serviceProvider.GetRequiredService(); + Assert.AreNotSame(instance1, instance2); // Transient instances should always differ + } + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/DI/Resource/UrlboxConfigTest.cs b/UrlboxSDK.MsTest/DI/Resource/UrlboxConfigTest.cs new file mode 100644 index 0000000..bc07d8b --- /dev/null +++ b/UrlboxSDK.MsTest/DI/Resource/UrlboxConfigTest.cs @@ -0,0 +1,85 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK.MsTest.DI.Resource +{ + [TestClass] + public class UrlboxConfigTests + { + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void UrlboxConfig_ThrowsArgumentException_WhenKeyIsMissing() + { + UrlboxConfig config = new() + { + Secret = "valid-secret" + }; + + config.Validate(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void UrlboxConfig_ThrowsArgumentException_WhenSecretIsMissing() + { + UrlboxConfig config = new() + { + Key = "valid-key" + }; + + config.Validate(); + } + + [TestMethod] + public void UrlboxConfig_CreatesInstance_WhenWebhookSecretIsNotProvided() + { + UrlboxConfig config = new() + { + Key = "valid-key", + Secret = "valid-secret" + }; + + Assert.IsNotNull(config); + Assert.AreEqual("valid-key", config.Key); + Assert.AreEqual("valid-secret", config.Secret); + Assert.IsNull(config.WebhookSecret); + Assert.AreEqual(Urlbox.BASE_URL, config.BaseUrl); + } + + [TestMethod] + public void UrlboxConfig_CreatesInstance_WhenWebhookSecretIsProvided() + { + UrlboxConfig config = new() + { + Key = "valid-key", + Secret = "valid-secret", + WebhookSecret = "webhook-secret" + }; + + Assert.IsNotNull(config); + Assert.AreEqual("valid-key", config.Key); + Assert.AreEqual("valid-secret", config.Secret); + Assert.AreEqual("webhook-secret", config.WebhookSecret); + Assert.AreEqual(Urlbox.BASE_URL, config.BaseUrl); + } + + [TestMethod] + public void UrlboxConfig_CreatesInstance_BaseUrl_andThrowsSet() + { + UrlboxConfig config = new() + { + Key = "valid-key", + Secret = "valid-secret", + WebhookSecret = "webhook-secret", + BaseUrl = "https://example.com", + }; + + Assert.IsNotNull(config); + Assert.AreEqual("valid-key", config.Key); + Assert.AreEqual("valid-secret", config.Secret); + Assert.AreEqual("webhook-secret", config.WebhookSecret); + Assert.AreEqual("https://example.com", config.BaseUrl); + } + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Exception/UrlboxExceptionTest.cs b/UrlboxSDK.MsTest/Exception/UrlboxExceptionTest.cs new file mode 100644 index 0000000..f400ac4 --- /dev/null +++ b/UrlboxSDK.MsTest/Exception/UrlboxExceptionTest.cs @@ -0,0 +1,105 @@ +using System; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Exception; + +namespace UrlboxSDK.MsTest.Exception; + +[TestClass] +public class UrlboxExceptionTests +{ + private readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + [TestMethod] + public void FromResponse_ValidResponse_ParsesSuccessfully() + { + string jsonResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors - {\""url\"":[\""error resolving URL - ENOTFOUND ffffffffffftest-site.urlbox.com\""]}"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND ffffffffffftest-site.urlbox.com\""]}"" + }, + ""requestId"": ""5490b293-29b7-43e6-b9f0-7ea23c6a1259"" + }"; + + UrlboxException exception = Assert.ThrowsException(() => UrlboxException.FromResponse(jsonResponse, _serializerOptions)); + + Assert.AreEqual("Invalid options, please check errors - {\"url\":[\"error resolving URL - ENOTFOUND ffffffffffftest-site.urlbox.com\"]}", exception.Message); + Assert.AreEqual("InvalidOptions", exception.Code); + Assert.AreEqual("{\"url\":[\"error resolving URL - ENOTFOUND ffffffffffftest-site.urlbox.com\"]}", exception.Errors); + Assert.AreEqual("5490b293-29b7-43e6-b9f0-7ea23c6a1259", exception.RequestId); + } + + [TestMethod] + public void FromResponse_ResponseWithMissingCodeAndErrors_ParsesSuccessfully() + { + string jsonResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors"", + ""code"": """", + ""errors"": """" + }, + ""requestId"": ""5490b293-29b7-43e6-b9f0-7ea23c6a1259"" + }"; + + UrlboxException exception = Assert.ThrowsException(() => UrlboxException.FromResponse(jsonResponse, _serializerOptions)); + + Assert.AreEqual("Invalid options, please check errors", exception.Message); + Assert.IsNull(exception.Code); + Assert.IsNull(exception.Errors); + Assert.AreEqual("5490b293-29b7-43e6-b9f0-7ea23c6a1259", exception.RequestId); + } + + [TestMethod] + public void FromResponse_InvalidJson_ThrowsException() + { + string invalidJson = @"{ ""invalid"": ""json"" }"; + + JsonException exception = Assert.ThrowsException(() => UrlboxException.FromResponse(invalidJson, _serializerOptions)); + + Assert.AreEqual("Invalid JSON response structure", exception.Message); + Assert.IsInstanceOfType(exception, typeof(JsonException)); + } + + [TestMethod] + public void FromResponse_NullOrEmptyResponse_ThrowsArgumentException() + { + Assert.ThrowsException(() => UrlboxException.FromResponse(null, _serializerOptions)); + Assert.ThrowsException(() => UrlboxException.FromResponse(string.Empty, _serializerOptions)); + } + + [TestMethod] + public void FromResponse_ResponseWithMissingRequestId_ThrowsJsonException() + { + string jsonResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND ffffffffffftest-site.urlbox.com\""]}"" + } + }"; + + JsonException exception = Assert.ThrowsException(() => UrlboxException.FromResponse(jsonResponse, _serializerOptions)); + + Assert.AreEqual("Invalid JSON response structure", exception.Message); + } + + [TestMethod] + public void FromResponse_ResponseWithMissingError_ThrowsJsonException() + { + string jsonResponse = @" + { + ""requestId"": ""5490b293-29b7-43e6-b9f0-7ea23c6a1259"" + }"; + + JsonException exception = Assert.ThrowsException(() => UrlboxException.FromResponse(jsonResponse, _serializerOptions)); + + Assert.AreEqual("Invalid JSON response structure", exception.Message); + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Factory/RenderLinkFactoryTest.cs b/UrlboxSDK.MsTest/Factory/RenderLinkFactoryTest.cs new file mode 100644 index 0000000..508a0f6 --- /dev/null +++ b/UrlboxSDK.MsTest/Factory/RenderLinkFactoryTest.cs @@ -0,0 +1,59 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Factory; +using UrlboxSDK.Options.Resource; +using System; + +namespace UrlboxSDK.MSTest.Factory +{ + [TestClass] + public class RenderLinkFactoryTests + { + private RenderLinkFactory renderLinkFactory; + private const string BaseUrl = "https://api.urlbox.com"; + private const string TestKey = "test-key"; + private const string TestSecret = "test-secret"; + + [TestInitialize] + public void Setup() + { + renderLinkFactory = new RenderLinkFactory(TestKey, TestSecret); + } + + [TestMethod] + public void GenerateRenderLink_SignTrue_ShouldReturnSignedUrl() + { + var options = new UrlboxOptions(url: "https://example.com"); + string expectedQueryString = "url=https%3A%2F%2Fexample.com"; + string expectedLinkUnsigned = $"{BaseUrl}/v1/{TestKey}/png?{expectedQueryString}"; + + string result = renderLinkFactory.GenerateRenderLink(BaseUrl, options, sign: true); + + Assert.IsTrue(result.Contains(expectedQueryString)); + Assert.IsTrue(result != expectedLinkUnsigned); + } + + [TestMethod] + public void GenerateRenderLink_SignFalse_ShouldReturnUnsignedUrl() + { + var options = new UrlboxOptions(url: "https://example.com"); + string expectedQueryString = "url=https%3A%2F%2Fexample.com"; + string expectedLink = $"{BaseUrl}/v1/{TestKey}/png?{expectedQueryString}"; + string result = renderLinkFactory.GenerateRenderLink(BaseUrl, options, sign: false); + + Assert.AreEqual(expectedLink, result); + } + + [TestMethod] + public void GenerateRenderLink_WithDiffFormatFormat_ShouldReturnExpectedLink() + { + var options = Urlbox.Options(url: "https://example.com").Format(Format.Jpeg).Build(); + + string expectedQueryString = "url=https%3A%2F%2Fexample.com"; + string expectedLink = $"{BaseUrl}/v1/{TestKey}/jpeg?{expectedQueryString}"; + + string result = renderLinkFactory.GenerateRenderLink(BaseUrl, options, sign: false); + + Assert.AreEqual(expectedLink, result); + } + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Factory/UrlboxFactoryTest.cs b/UrlboxSDK.MsTest/Factory/UrlboxFactoryTest.cs new file mode 100644 index 0000000..b15c78f --- /dev/null +++ b/UrlboxSDK.MsTest/Factory/UrlboxFactoryTest.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Config.Resource; +using UrlboxSDK.Factory; + +namespace UrlboxSDK.MSTest.Factory +{ + [TestClass] + public class UrlboxFactoryTests + { + private IUrlboxFactory factory; + + [TestInitialize] + public void Setup() + { + factory = new UrlboxFactory(); + } + + [TestMethod] + public void Create_ShouldReturnInstanceOfIUrlbox() + { + string key = "test-key"; + string secret = "test-secret"; + + UrlboxConfig config = new() + { + Key = key, + Secret = secret + }; + var result = factory.Create(config); + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(IUrlbox)); + } + + [TestMethod] + public void Create_WithWebhookSecret_ShouldReturnValidInstance() + { + string key = "test-key"; + string secret = "test-secret"; + string webhookSecret = "test-webhook-secret"; + UrlboxConfig config = new() + { + Key = key, + Secret = secret, + WebhookSecret = webhookSecret + }; + var result = factory.Create(config); + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(IUrlbox)); + } + + + [TestMethod] + public void FromCredentials_Success() + { + Urlbox urlbox = UrlboxFactory.FromCredentials("test_key", "test_secret", "test_webhook"); + Assert.IsInstanceOfType(urlbox, typeof(Urlbox)); + } + + [TestMethod] + public void FromCredentials_Exception() + { + Assert.ThrowsException(() => UrlboxFactory.FromCredentials("", "", "")); + } + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Metadata/Resource/OgImageTest.cs b/UrlboxSDK.MsTest/Metadata/Resource/OgImageTest.cs new file mode 100644 index 0000000..d58dcf9 --- /dev/null +++ b/UrlboxSDK.MsTest/Metadata/Resource/OgImageTest.cs @@ -0,0 +1,24 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Metadata.Resource; + +namespace UrlboxSDK.MsTest.Metadata.Resource; + +[TestClass] +public class OgImageTests +{ + [TestMethod] + public void OgImage_CreatesGetters() + { + OgImage ogImage = new( + url: "url", + type: "type", + width: "123", + height: "123" + ); + Assert.IsInstanceOfType(ogImage, typeof(OgImage)); + Assert.AreEqual("url", ogImage.Url); + Assert.AreEqual("type", ogImage.Type); + Assert.AreEqual("123", ogImage.Width); + Assert.AreEqual("123", ogImage.Height); + } +} diff --git a/UrlboxSDK.MsTest/Metadata/Resource/UrlboxMetadataTest.cs b/UrlboxSDK.MsTest/Metadata/Resource/UrlboxMetadataTest.cs new file mode 100644 index 0000000..760cc32 --- /dev/null +++ b/UrlboxSDK.MsTest/Metadata/Resource/UrlboxMetadataTest.cs @@ -0,0 +1,127 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Metadata.Resource; + +namespace UrlboxSDK.MsTest.Metadata.Resource; + +[TestClass] +public class UrlboxMetadataTests +{ + [TestMethod] + public void UrlboxMetadata_CreatesGettersMin() + { + string url = "url"; + string urlRequested = "urlRequested"; + string urlResolved = "urlResolved"; + + UrlboxMetadata urlboxMetadata = new( + url: url, + urlRequested: urlRequested, + urlResolved: urlResolved + ); + + Assert.IsInstanceOfType(urlboxMetadata, typeof(UrlboxMetadata)); + Assert.AreEqual(url, urlboxMetadata.Url); + Assert.AreEqual(urlRequested, urlboxMetadata.UrlRequested); + Assert.AreEqual(urlResolved, urlboxMetadata.UrlResolved); + + Assert.IsNull(urlboxMetadata.Author); + Assert.IsNull(urlboxMetadata.Date); + Assert.IsNull(urlboxMetadata.Description); + Assert.IsNull(urlboxMetadata.Image); + Assert.IsNull(urlboxMetadata.Logo); + Assert.IsNull(urlboxMetadata.Publisher); + Assert.IsNull(urlboxMetadata.Title); + Assert.IsNull(urlboxMetadata.OgTitle); + Assert.IsNull(urlboxMetadata.OgImage); + Assert.IsNull(urlboxMetadata.OgDescription); + Assert.IsNull(urlboxMetadata.OgUrl); + Assert.IsNull(urlboxMetadata.OgType); + Assert.IsNull(urlboxMetadata.OgSiteName); + Assert.IsNull(urlboxMetadata.OgImage); + Assert.IsNull(urlboxMetadata.OgLocale); + Assert.IsNull(urlboxMetadata.Charset); + Assert.IsNull(urlboxMetadata.TwitterCard); + Assert.IsNull(urlboxMetadata.TwitterSite); + Assert.IsNull(urlboxMetadata.TwitterCreator); + } + + [TestMethod] + public void UrlboxMetadata_CreatesGettersAll() + { + OgImage ogImage = new( + url: "url", + type: "type", + width: "123", + height: "123" + ); + + string author = "author"; + string date = "date"; + string description = "description"; + string image = "image"; + string logo = "logo"; + string publisher = "publisher"; + string title = "title"; + string url = "url"; + string ogTitle = "ogTitle"; + OgImage[] ogImages = new OgImage[] { ogImage, ogImage }; + string ogDescription = "ogDescription"; + string ogUrl = "ogUrl"; + string ogType = "ogType"; + string ogSiteName = "ogSiteName"; + string ogLocale = "ogLocale"; + string charset = "charset"; + string urlRequested = "urlRequested"; + string urlResolved = "urlResolved"; + string twitterCard = "twitterCard"; + string twitterSite = "twitterSite"; + string twitterCreator = "twitterCreator"; + + UrlboxMetadata urlboxMetadata = new( + author: author, + date: date, + description: description, + image: image, + logo: logo, + publisher: publisher, + title: title, + url: url, + ogTitle: ogTitle, + ogImage: ogImages, + ogDescription: ogDescription, + ogUrl: ogUrl, + ogType: ogType, + ogSiteName: ogSiteName, + ogLocale: ogLocale, + charset: charset, + urlRequested: urlRequested, + urlResolved: urlResolved, + twitterCard: twitterCard, + twitterSite: twitterSite, + twitterCreator: twitterCreator + ); + + Assert.IsInstanceOfType(urlboxMetadata, typeof(UrlboxMetadata)); + Assert.AreEqual(author, urlboxMetadata.Author); + Assert.AreEqual(date, urlboxMetadata.Date); + Assert.AreEqual(description, urlboxMetadata.Description); + Assert.AreEqual(image, urlboxMetadata.Image); + Assert.AreEqual(logo, urlboxMetadata.Logo); + Assert.AreEqual(publisher, urlboxMetadata.Publisher); + Assert.AreEqual(title, urlboxMetadata.Title); + Assert.AreEqual(url, urlboxMetadata.Url); + Assert.AreEqual(ogTitle, urlboxMetadata.OgTitle); + Assert.AreEqual(ogImages, urlboxMetadata.OgImage); + Assert.AreEqual(ogDescription, urlboxMetadata.OgDescription); + Assert.AreEqual(ogUrl, urlboxMetadata.OgUrl); + Assert.AreEqual(ogType, urlboxMetadata.OgType); + Assert.AreEqual(ogSiteName, urlboxMetadata.OgSiteName); + Assert.AreEqual(ogLocale, urlboxMetadata.OgLocale); + Assert.AreEqual(charset, urlboxMetadata.Charset); + Assert.AreEqual(urlRequested, urlboxMetadata.UrlRequested); + Assert.AreEqual(urlResolved, urlboxMetadata.UrlResolved); + Assert.AreEqual(twitterCard, urlboxMetadata.TwitterCard); + Assert.AreEqual(twitterSite, urlboxMetadata.TwitterSite); + Assert.AreEqual(twitterCreator, urlboxMetadata.TwitterCreator); + } +} diff --git a/UrlboxSDK.MsTest/Options/Builder/UrlboxOptionsBuilderTest.cs b/UrlboxSDK.MsTest/Options/Builder/UrlboxOptionsBuilderTest.cs new file mode 100644 index 0000000..62a97c2 --- /dev/null +++ b/UrlboxSDK.MsTest/Options/Builder/UrlboxOptionsBuilderTest.cs @@ -0,0 +1,708 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Options.Builder; +using UrlboxSDK.Options.Resource; + +namespace UrlboxSDK.MsTest.Options.Builder; + +[TestClass] +public class UrlboxOptionsBuilderTests +{ + + [TestMethod] + public void BasicOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Format(Format.Png) + .Width(1280) + .Height(720) + .FullPage() + .Selector("#main") + .Build(); + + Assert.AreEqual(Format.Png, options.Format); + Assert.AreEqual(1280, options.Width); + Assert.AreEqual(720, options.Height); + Assert.IsTrue(options.FullPage.HasValue && options.FullPage == true); + Assert.AreEqual("#main", options.Selector); + } + + [TestMethod] + public void BlockingOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .BlockAds() + .HideCookieBanners() + .ClickAccept() + .BlockUrls("https://ads.example.com", "https://trackers.example.com") + .BlockImages() + .BlockFonts() + .BlockMedias() + .BlockStyles() + .BlockScripts() + .BlockFrames() + .BlockFetch() + .BlockXhr() + .BlockSockets() + .Build(); + + Assert.IsTrue(options.BlockAds.HasValue && options.BlockAds == true); + Assert.IsTrue(options.HideCookieBanners.HasValue && options.HideCookieBanners == true); + Assert.IsTrue(options.ClickAccept.HasValue && options.ClickAccept == true); + if (options.BlockUrls.Length > 0) + { + CollectionAssert.AreEqual(new[] { "https://ads.example.com", "https://trackers.example.com" }, options.BlockUrls); + } + Assert.IsTrue(options.BlockImages.HasValue && options.BlockImages == true); + Assert.IsTrue(options.BlockFonts.HasValue && options.BlockFonts == true); + Assert.IsTrue(options.BlockMedias.HasValue && options.BlockMedias == true); + Assert.IsTrue(options.BlockStyles.HasValue && options.BlockStyles == true); + Assert.IsTrue(options.BlockScripts.HasValue && options.BlockScripts == true); + Assert.IsTrue(options.BlockFrames.HasValue && options.BlockFrames == true); + Assert.IsTrue(options.BlockFetch.HasValue && options.BlockFetch == true); + Assert.IsTrue(options.BlockXhr.HasValue && options.BlockXhr == true); + Assert.IsTrue(options.BlockSockets.HasValue && options.BlockSockets == true); + } + + [TestMethod] + public void CustomizeOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Js("document.body.style.backgroundColor = 'lightblue';") + .Css("body { font-size: 16px; }") + .DarkMode() + .ReducedMotion() + .Retina() + .Build(); + + Assert.AreEqual("document.body.style.backgroundColor = 'lightblue';", options.Js); + Assert.AreEqual("body { font-size: 16px; }", options.Css); + Assert.IsTrue(options.DarkMode.HasValue && options.DarkMode == true); + Assert.IsTrue(options.ReducedMotion.HasValue && options.ReducedMotion == true); + Assert.IsTrue(options.Retina.HasValue && options.Retina == true); + + } + + [TestMethod] + public void ScreenshotOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .ThumbWidth(200) + .ThumbHeight(150) + .ImgFit(ImgFit.Cover) + .ImgPosition(ImgPosition.Center) + .ImgBg("#FFFFFF") + .ImgPad("10") + .Quality(90) + .Transparent() + .MaxHeight(2000) + .Download("screenshot.png") + .Build(); + + Assert.AreEqual(200, options.ThumbWidth); + Assert.AreEqual(150, options.ThumbHeight); + Assert.AreEqual(ImgFit.Cover, options.ImgFit); + Assert.AreEqual(ImgPosition.Center, options.ImgPosition); + Assert.AreEqual("#FFFFFF", options.ImgBg); + Assert.AreEqual("10", options.ImgPad); + Assert.AreEqual(90, options.Quality); + Assert.IsTrue(options.Transparent.HasValue && options.Transparent == true); + Assert.AreEqual(2000, options.MaxHeight); + Assert.AreEqual("screenshot.png", options.Download); + } + + [TestMethod] + public void PdfOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Format(Format.Pdf) + .PdfPageSize(PdfPageSize.A4) + .PdfPageRange("1-2") + .PdfPageWidth(210) + .PdfPageHeight(297) + .PdfMargin(PdfMargin.Default) + .PdfMarginTop(10) + .PdfMarginRight(10) + .PdfMarginBottom(10) + .PdfMarginLeft(10) + .PdfAutoCrop() + .PdfScale(1.0) + .PdfOrientation(PdfOrientation.Portrait) + .PdfBackground() + .DisableLigatures() + .Media(Media.Print) + .PdfShowHeader() + .PdfHeader("Header content") + .PdfShowFooter() + .PdfFooter("Footer content") + .Build(); + + Assert.AreEqual(Format.Pdf, options.Format); + Assert.AreEqual(PdfPageSize.A4, options.PdfPageSize); + Assert.AreEqual("1-2", options.PdfPageRange); + Assert.AreEqual(210, options.PdfPageWidth); + Assert.AreEqual(297, options.PdfPageHeight); + Assert.AreEqual(PdfMargin.Default, options.PdfMargin); + Assert.AreEqual(10, options.PdfMarginTop); + Assert.AreEqual(10, options.PdfMarginRight); + Assert.AreEqual(10, options.PdfMarginBottom); + Assert.AreEqual(10, options.PdfMarginLeft); + Assert.IsTrue(options.PdfAutoCrop.HasValue && options.PdfAutoCrop == true); + Assert.AreEqual(1.0, options.PdfScale); + Assert.AreEqual(PdfOrientation.Portrait, options.PdfOrientation); + Assert.IsTrue(options.PdfBackground.HasValue && options.PdfBackground == true); + Assert.IsTrue(options.DisableLigatures.HasValue && options.DisableLigatures == true); + Assert.AreEqual(Media.Print, options.Media); + Assert.IsTrue(options.PdfShowHeader.HasValue && options.PdfShowHeader == true); + Assert.AreEqual("Header content", options.PdfHeader); + Assert.IsTrue(options.PdfShowFooter.HasValue && options.PdfShowFooter == true); + Assert.AreEqual("Footer content", options.PdfFooter); + } + + [TestMethod] + public void CacheOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Force() + .Unique("unique-id") + .Ttl(3600) + .Build(); + + Assert.IsTrue(options.Force.HasValue && options.Force == true); + Assert.AreEqual("unique-id", options.Unique); + Assert.AreEqual(3600, options.Ttl); + } + + [TestMethod] + public void RequestOptions_ShouldSetCorrectly() + { + string cookieAsParam = "sessionid=abc123"; + string[] cookie = { cookieAsParam }; + string[] expectedHeaderValue = new[] { "value1", "value2" }; + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Header(expectedHeaderValue) + .Cookie(cookieAsParam) + .UserAgent("Mozilla/5.0") + .Platform("Win32") + .AcceptLang("en-US") + .Authorization("Bearer token") + .Tz("UTC") + .EngineVersion(EngineVersion.Latest) + .Build(); + + Assert.IsInstanceOfType(options.Header, typeof(string[]), "Header should be a string[]."); + CollectionAssert.AreEqual(expectedHeaderValue, options.Header); + + CollectionAssert.AreEqual(cookie, options.Cookie, "Cookie should be set correctly."); + Assert.AreEqual("Mozilla/5.0", options.UserAgent); + Assert.AreEqual("Win32", options.Platform); + Assert.AreEqual("en-US", options.AcceptLang); + Assert.AreEqual("Bearer token", options.Authorization); + Assert.AreEqual("UTC", options.Tz); + Assert.AreEqual(EngineVersion.Latest, options.EngineVersion); + } + + [TestMethod] + public void WaitOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options(url: "https://example.com") + .Delay(1000) + .Timeout(30000) + .WaitUntil(WaitUntil.Domloaded) + .WaitFor("#content") + .WaitToLeave(".loading") + .WaitTimeout(5000) + .FailIfSelectorMissing() + .FailIfSelectorPresent() + .FailOn4xx() + .FailOn5xx() + .Build(); + + Assert.AreEqual(1000, options.Delay); + Assert.AreEqual(30000, options.Timeout); + Assert.AreEqual(WaitUntil.Domloaded, options.WaitUntil); + Assert.AreEqual("#content", options.WaitFor); + Assert.AreEqual(".loading", options.WaitToLeave); + Assert.AreEqual(5000, options.WaitTimeout); + Assert.IsTrue(options.FailIfSelectorMissing.HasValue && options.FailIfSelectorMissing == true); + Assert.IsTrue(options.FailIfSelectorPresent.HasValue && options.FailIfSelectorPresent == true); + Assert.IsTrue(options.FailOn4Xx.HasValue && options.FailOn4Xx == true); + Assert.IsTrue(options.FailOn5Xx.HasValue && options.FailOn4Xx == true); + } + + [TestMethod] + public void AllOptions_ShouldSetCorrectly() + { + UrlboxOptions options = Urlbox.Options( + url: "https://urlbox.com" + ) + .WebhookUrl("https://example.com/webhook") + .Format(Format.Pdf) + .Width(1024) + .Height(768) + .FullPage() + .Selector("#content") + .Clip("0,0,400,400") + .Gpu() + .BlockAds() + .HideCookieBanners() + .ClickAccept() + .BlockUrls("https://ads.com", "https://trackers.com") + .BlockImages() + .BlockFonts() + .BlockMedias() + .BlockStyles() + .BlockScripts() + .BlockFrames() + .BlockFetch() + .BlockXhr() + .BlockSockets() + .HideSelector(".banner") + .Js("alert('Hello');") + .Css("body { background: red; }") + .DarkMode() + .ReducedMotion() + .Retina() + .ThumbWidth(150) + .ThumbHeight(150) + .ImgFit(ImgFit.Cover) + .ImgPosition(ImgPosition.Center) + .ImgBg("#FFFFFF") + .ImgPad("10") + .Quality(90) + .Transparent() + .MaxHeight(2000) + .Download("download.png") + .PdfPageSize(PdfPageSize.A4) + .PdfPageRange("1-2") + .PdfPageWidth(210) + .PdfPageHeight(297) + .PdfMargin(PdfMargin.Default) + .PdfMarginTop(10) + .PdfMarginRight(10) + .PdfMarginBottom(10) + .PdfMarginLeft(10) + .PdfAutoCrop() + .PdfScale(1.0) + .PdfOrientation(PdfOrientation.Portrait) + .PdfBackground() + .DisableLigatures() + .Media(Media.Screen) + .PdfShowHeader() + .PdfHeader("Header content") + .PdfShowFooter() + .PdfFooter("Footer content") + .Readable() + .Force() + .Unique("unique-id") + .Ttl(3600) + .Proxy("http://proxyserver.com") + .Header(new string[] { "Authorization: Bearer token" }) + .Cookie("sessionid=abc123") + .UserAgent("Mozilla/5.0") + .Platform("Win32") + .AcceptLang("en-US") + .Authorization("Bearer token") + .Tz("UTC") + .EngineVersion(EngineVersion.Latest) + .Delay(1000) + .Timeout(30000) + .WaitUntil(WaitUntil.Domloaded) + .WaitFor("#content") + .WaitToLeave(".loading") + .WaitTimeout(5000) + .FailIfSelectorMissing() + .FailIfSelectorPresent() + .FailOn4xx() + .FailOn5xx() + .ScrollTo("#bottom") + .Click("#button") + .ClickAll(".buttons") + .Hover(".hover-element") + .BgColor("#FAFAFA") + .DisableJs() + .FullPageMode(FullPageMode.Stitch) + .FullWidth() + .AllowInfinite() + .SkipScroll() + .DetectFullHeight() + .MaxSectionHeight(500) + .ScrollIncrement(200) + .ScrollDelay(100) + .Highlight("#highlight") + .Highlightfg("#FF0000") + .Highlightbg("#FFFF00") + .Latitude(37.7749) + .Longitude(-122.4194) + .Accuracy(10) + .UseS3() + .S3Path("/screenshots") + .S3Bucket("my-s3-bucket") + .S3Endpoint("https://s3.amazonaws.com") + .S3Region("us-west-1") + .CdnHost("https://cdn.example.com") + .S3Storageclass(S3Storageclass.Standard) + .SaveHtml() + .SaveMhtml() + .SaveMarkdown() + .SaveMetadata() + .Metadata() + .Build(); + + Assert.IsInstanceOfType(options, typeof(UrlboxOptions)); + Assert.AreEqual("https://urlbox.com", options.Url); + } + + [TestMethod] + public void ValidateFullPageOptions_throws() + { + // FullPageMode should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .FullPageMode(FullPageMode.Stitch) + .Build(); + }); + + // ScrollIncrement should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .ScrollIncrement(100) + .Build(); + }); + + // ScrollDelay should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .ScrollDelay(500) + .Build(); + }); + + // DetectFullHeight should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .DetectFullHeight() + .Build(); + }); + + // MaxSectionHeight should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .MaxSectionHeight(2000) + .Build(); + }); + + // FullWidth should throw an exception + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .FullWidth() + .Build(); + }); + } + + [TestMethod] + public void ValidateS3Options_throws() + { + // S3Path should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .S3Path("/path/to/object") + .Build(); + }); + + // S3Bucket should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .S3Bucket("my-s3-bucket") + .Build(); + }); + + // S3Endpoint should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .S3Endpoint("https://s3.amazonaws.com") + .Build(); + }); + + // S3Region should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .S3Region("us-west-2") + .Build(); + }); + + // CdnHost should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .CdnHost("https://cdn.myhost.com") + .Build(); + }); + + // S3StorageClass should throw an exception if use_s3 is not enabled + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Format(Format.Png) + .S3Storageclass(S3Storageclass.Standard) + .Build(); + }); + } + + [TestMethod] + public void ValidatePdfOptions_throws() + { + UrlboxOptionsBuilder builder = new(url: "https://urlbox.com"); + + // PdfPageSize should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfPageSize(PdfPageSize.A4) + .Build(); + }); + + // PdfPageRange should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfPageRange("1-3") + .Build(); + }); + + // PdfPageWidth should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfPageWidth(210) + .Build(); + }); + + // PdfPageHeight should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfPageHeight(297) + .Build(); + }); + + // PdfMargin should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfMargin(PdfMargin.Default) + .Build(); + }); + + // PdfMarginTop should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfMarginTop(5) + .Build(); + }); + + // PdfMarginRight should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfMarginRight(5) + .Build(); + }); + + // PdfMarginBottom should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfMarginBottom(5) + .Build(); + }); + + // PdfMarginLeft should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfMarginLeft(5) + .Build(); + }); + + // PdfAutoCrop should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfAutoCrop() + .Build(); + }); + + // PdfScale should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfScale(1.5) + .Build(); + }); + + // PdfOrientation should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfOrientation(PdfOrientation.Portrait) + .Build(); + }); + + // PdfBackground should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfBackground() + .Build(); + }); + + // DisableLigatures should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").DisableLigatures() + .Build(); + }); + + // Media should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Media(Media.Print) + .Build(); + }); + + // Readable should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").Readable() + .Build(); + }); + + // PdfShowHeader should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfShowHeader() + .Build(); + }); + + // PdfHeader should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfHeader("Header Content") + .Build(); + }); + + // PdfShowFooter should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfShowFooter() + .Build(); + }); + + // PdfFooter should throw an exception if format is not "pdf" + Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").PdfFooter("Footer Content") + .Build(); + }); + } + + [TestMethod] + public void ValidateScreenshotOptions_throws() + { + // No thumb width or height but includes img fit + ArgumentException noThumbButImgFit = Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com").ImgFit(ImgFit.Cover) + .Build(); + }); + + Assert.AreEqual( + "Invalid Configuration: Image Fit is included despite ThumbWidth nor ThumbHeight being set.", + noThumbButImgFit.Message + ); + + ArgumentException thumbAndPositionButNoFit = Assert.ThrowsException(() => + { + Urlbox.Options(url: "https://urlbox.com") + .ThumbHeight(5) + .ImgPosition(ImgPosition.North) + .Build(); + }); + + Assert.AreEqual( + "Invalid Configuration: Image Position is included despite Image Fit not being set.", + thumbAndPositionButNoFit.Message + ); + } + + [TestMethod] + public void ValidateScreenshotOptions_succeeds() + { + UrlboxOptions heightAndImgFit = + Urlbox.Options(url: "https://urlbox.com") + .ThumbHeight(5) + .ImgFit(ImgFit.Cover) + .Build(); + + UrlboxOptions widthAndImgFit = + Urlbox.Options(url: "https://urlbox.com") + .ThumbWidth(5) + .ImgFit(ImgFit.Cover) + .Build(); + + UrlboxOptions justThumbHeight = + Urlbox.Options(url: "https://urlbox.com") + .ThumbHeight(5) + .Build(); + + UrlboxOptions justThumbWidth = + Urlbox.Options(url: "https://urlbox.com") + .ThumbWidth(5) + .Build(); + + UrlboxOptions heightAndImgFitCoverAndPosition = + Urlbox.Options(url: "https://urlbox.com") + .ThumbHeight(5) + .ImgFit(ImgFit.Cover) + .ImgPosition(ImgPosition.North) + .Build(); + + UrlboxOptions heightAndImgFitContainAndPosition = + Urlbox.Options(url: "https://urlbox.com") + .ThumbHeight(5) + .ImgFit(ImgFit.Contain) + .Build(); + + UrlboxOptions widthAndImgFitCoverAndPosition = + Urlbox.Options(url: "https://urlbox.com") + .ThumbWidth(5) + .ImgFit(ImgFit.Cover) + .ImgPosition(ImgPosition.North) + .Build(); + + UrlboxOptions widthAndImgFitContainAndPosition = + Urlbox.Options(url: "https://urlbox.com") + .ThumbWidth(5) + .ImgFit(ImgFit.Contain) + .Build(); + + Assert.IsInstanceOfType(justThumbHeight, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(justThumbWidth, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(heightAndImgFit, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(heightAndImgFitCoverAndPosition, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(heightAndImgFitContainAndPosition, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(widthAndImgFitCoverAndPosition, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(widthAndImgFitContainAndPosition, typeof(UrlboxOptions)); + Assert.IsInstanceOfType(widthAndImgFit, typeof(UrlboxOptions)); + } + + [TestMethod] + public void UrlboxOptionsBuilder_Resets() + { + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com") + .FullPage() + .Build(); + + UrlboxOptions otherOptions = Urlbox.Options(url: "https://someotherurl.com").Build(); + + Assert.IsNull(otherOptions.FullPage); + Assert.AreNotSame(options, otherOptions); + } +} diff --git a/UrlboxSDK.MsTest/Options/Resource/UrlboxOptionsTest.cs b/UrlboxSDK.MsTest/Options/Resource/UrlboxOptionsTest.cs new file mode 100644 index 0000000..2ff9c38 --- /dev/null +++ b/UrlboxSDK.MsTest/Options/Resource/UrlboxOptionsTest.cs @@ -0,0 +1,63 @@ +#nullable enable + +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Options.Resource; + +namespace UrlboxSDK.MsTest.Options.Resource; + +[TestClass] +public class UrlboxOptionsTest +{ + [TestMethod] + public void UrlboxOptions_MissingHTMLandURL() + { + Assert.ThrowsException(() => new UrlboxOptions()); + } + + [TestMethod] + public void UrlboxOptions_BothHTMLandURL() + { + ArgumentException exception = Assert.ThrowsException(() => new UrlboxOptions(url: "urlbox.com", html: "

test

")); + Assert.AreEqual(exception.Message, "Either but not both options 'url' or 'html' must be provided."); + } + + [TestMethod] + public void UrlboxOptions_CreatesSuccess_URL() + { + string url = "https://urlbox.com"; + UrlboxOptions urlboxOptions = new(url: url); + + Assert.IsNotNull(urlboxOptions); + Assert.IsInstanceOfType(urlboxOptions, typeof(UrlboxOptions)); + Assert.AreEqual(url, urlboxOptions.Url); + Assert.IsNull(urlboxOptions.Html); + } + + [TestMethod] + public void UrlboxOptions_CreatesSuccess_HTML() + { + string html = "

test

"; + UrlboxOptions urlboxOptions = new(html: html); + + Assert.IsNotNull(urlboxOptions); + Assert.IsInstanceOfType(urlboxOptions, typeof(UrlboxOptions)); + Assert.AreEqual(html, urlboxOptions.Html); + Assert.IsNull(urlboxOptions.Url); + } + + /// + /// Tests that you can dynamically assign options on construct + /// + [TestMethod] + public void UrlboxOptions_CreatedOnInit() + { + string html = "

test

"; + UrlboxOptions urlboxOptions = new(html: html) + { + Format = Format.Pdf + }; + + Assert.IsTrue(urlboxOptions.Format == Format.Pdf); + } +} diff --git a/UrlboxSDK.MsTest/Policy/SnakeCaseNamingPolicyTests.cs b/UrlboxSDK.MsTest/Policy/SnakeCaseNamingPolicyTests.cs new file mode 100644 index 0000000..bb744d1 --- /dev/null +++ b/UrlboxSDK.MsTest/Policy/SnakeCaseNamingPolicyTests.cs @@ -0,0 +1,40 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Policy; + +namespace UrlboxSDK.MsTest.Policy; + +[TestClass] +public class SnakeCaseNamingPolicyTests +{ + [TestMethod] + public void ConvertName_ShouldConvertPascalCaseToSnakeCase() + { + SnakeCaseNamingPolicy namingPolicy = new(); + + Assert.AreEqual("fail_on_4xx", namingPolicy.ConvertName("FailOn4xx")); + Assert.AreEqual("fail_on_400", namingPolicy.ConvertName("FailOn400")); + Assert.AreEqual("fail_on_5xx", namingPolicy.ConvertName("FailOn5xx")); + Assert.AreEqual("fail_on_500", namingPolicy.ConvertName("FailOn500")); + Assert.AreEqual("error_500_x", namingPolicy.ConvertName("Error500X")); + Assert.AreEqual("test_4xx_code", namingPolicy.ConvertName("Test4xxCode")); + Assert.AreEqual("full_page", namingPolicy.ConvertName("FullPage")); + } + + [TestMethod] + public void ConvertName_ShouldHandleSingleWordInputs() + { + SnakeCaseNamingPolicy namingPolicy = new(); + + Assert.AreEqual("example", namingPolicy.ConvertName("Example")); + Assert.AreEqual("test", namingPolicy.ConvertName("Test")); + } + + [TestMethod] + public void ConvertName_ShouldPreserveAlreadySnakeCaseNames() + { + SnakeCaseNamingPolicy namingPolicy = new(); + + Assert.AreEqual("already_snake_case", namingPolicy.ConvertName("already_snake_case")); + Assert.AreEqual("test_4xx", namingPolicy.ConvertName("test_4xx")); + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Resource/UrlboxBaseUrlTest.cs b/UrlboxSDK.MsTest/Resource/UrlboxBaseUrlTest.cs new file mode 100644 index 0000000..71e791b --- /dev/null +++ b/UrlboxSDK.MsTest/Resource/UrlboxBaseUrlTest.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Config.Resource; +using UrlboxSDK.Factory; + +namespace UrlboxSDK.MsTest.Resource; + +[TestClass] +public class UrlboxRegionTest +{ + [TestMethod] + public void Baseurl_NotSet() + { + Urlbox fromCredentials = UrlboxFactory.FromCredentials("MY_API_KEY", "secret", "webhook_secret"); + Assert.IsInstanceOfType(fromCredentials, typeof(Urlbox)); + Urlbox fromNew = UrlboxFactory.FromCredentials("MY_API_KEY", "secret", "webhook_secret"); + Assert.IsInstanceOfType(fromNew, typeof(Urlbox)); + } + + [TestMethod] + public void Baseurl_included() + { + UrlboxFactory factory = new(); + UrlboxConfig config = new() + { + Key = "test-key", + Secret = "test-secret", + WebhookSecret = "test-webhook", + BaseUrl = "https://test-urlbox.com" + }; + IUrlbox urlbox = factory.Create(config); + Assert.IsInstanceOfType(urlbox, typeof(Urlbox)); + } +} diff --git a/UrlboxSDK.MsTest/Resource/UrlboxResponseTest.cs b/UrlboxSDK.MsTest/Resource/UrlboxResponseTest.cs new file mode 100644 index 0000000..d3c3933 --- /dev/null +++ b/UrlboxSDK.MsTest/Resource/UrlboxResponseTest.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Metadata.Resource; +using UrlboxSDK.Response.Resource; + +namespace UrlboxSDK.MsTest.Resource; + +[TestClass] +public class SyncUrlboxResponseTests +{ + [TestMethod] + public void SyncUrlboxResponse_SuccessGetters() + { + string renderUrl = "renderurl"; + int size = 123; + SyncUrlboxResponse response = new(renderUrl, size); + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.AreEqual(renderUrl, response.RenderUrl); + Assert.AreEqual(size, response.Size); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithHtmlGetters() + { + SyncUrlboxResponse response = new("renderurl", 123, htmlUrl: "url.html"); + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.IsNotNull(response.HtmlUrl); + } + + [TestMethod] + public void SyncUrlboxResponse_HtmlBadExtension() + { + Assert.ThrowsException(() => new SyncUrlboxResponse("renderurl", 123, htmlUrl: "url.png")); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithMhtml() + { + SyncUrlboxResponse response = new("renderurl", 123, mhtmlUrl: "url.mhtml"); + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.IsNotNull(response.MhtmlUrl); + } + + [TestMethod] + public void SyncUrlboxResponse_MhtmlBadExtension() + { + Assert.ThrowsException(() => new SyncUrlboxResponse("renderurl", 123, mhtmlUrl: "url.png")); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithMarkdown() + { + SyncUrlboxResponse response = new("renderurl", 123, markdownUrl: "url.md"); + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.IsNotNull(response.MarkdownUrl); + } + + [TestMethod] + public void SyncUrlboxResponse_MarkdownBadExtension() + { + Assert.ThrowsException(() => new SyncUrlboxResponse("renderurl", 123, mhtmlUrl: "url.png")); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithMetadataUrl() + { + SyncUrlboxResponse response = new("renderurl", 123, metadataUrl: "url.json"); + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.IsNotNull(response.MetadataUrl); + } + + [TestMethod] + public void SyncUrlboxResponse_MetadataBadExtension() + { + Assert.ThrowsException(() => new SyncUrlboxResponse("renderurl", 123, mhtmlUrl: "url.png")); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithMetadata() + { + OgImage ogImage = new( + url: "url", + type: "type", + width: "123", + height: "123" + ); + OgImage[] ogImages = new OgImage[] { ogImage, ogImage }; + UrlboxMetadata urlboxMetadata = new( + author: "author", + date: "date", + description: "description", + image: "image", + logo: "logo", + publisher: "publisher", + title: "title", + url: "url", + ogTitle: "ogTitle", + ogImage: ogImages, + ogLocale: "ogLocale", + charset: "charset", + urlRequested: "urlRequested", + urlResolved: "urlResolved" + ); + SyncUrlboxResponse response = new("renderurl", 123, metadata: urlboxMetadata); + + Assert.IsInstanceOfType(response, typeof(SyncUrlboxResponse)); + Assert.IsNotNull(response.Metadata); + } + + [TestMethod] + public void SyncUrlboxResponse_SuccessWithAll() + { + OgImage ogImage = new(url: "url", type: "type", width: "123", height: "123"); + UrlboxMetadata urlboxMetadata = new( + author: "author", + date: "date", + description: "description", + image: "image", + logo: "logo", + publisher: "publisher", + title: "title", + url: "url", + ogTitle: "ogTitle", + ogImage: new OgImage[] { ogImage, ogImage }, + ogLocale: "ogLocale", + charset: "charset", + urlRequested: "urlRequested", + urlResolved: "urlResolved" + ); + + SyncUrlboxResponse response = new( + "renderurl", + 123, + metadataUrl: "url.json", + markdownUrl: "url.md", + htmlUrl: "url.html", + mhtmlUrl: "url.mhtml", + metadata: urlboxMetadata + ); + Assert.IsInstanceOfType(response, + typeof(SyncUrlboxResponse) + ); + + Assert.IsNotNull(response.MetadataUrl); + Assert.IsNotNull(response.MarkdownUrl); + Assert.IsNotNull(response.HtmlUrl); + Assert.IsNotNull(response.MhtmlUrl); + Assert.IsNotNull(response.Size); + Assert.IsNotNull(response.RenderUrl); + Assert.IsNotNull(response.Metadata); + } +} + +[TestClass] +public class AsyncUrlboxResponseTests +{ + [TestMethod] + public void AsyncUrlboxResponse_CreatesMinGetters() + { + AsyncUrlboxResponse response = new(renderId: "renderId", statusUrl: "statusUrl", status: "succeeded"); + Assert.IsInstanceOfType(response, typeof(AsyncUrlboxResponse)); + Assert.AreEqual("succeeded", response.Status); + Assert.AreEqual("statusUrl", response.StatusUrl); + Assert.AreEqual("renderId", response.RenderId); + } +} diff --git a/UrlboxSDK.MsTest/Test.runsettings b/UrlboxSDK.MsTest/Test.runsettings new file mode 100644 index 0000000..bd175ec --- /dev/null +++ b/UrlboxSDK.MsTest/Test.runsettings @@ -0,0 +1,12 @@ + + + + + + + 4 + + MethodLevel + + + \ No newline at end of file diff --git a/UrlboxSDK.MsTest/UrlboxSDK.MsTest.csproj b/UrlboxSDK.MsTest/UrlboxSDK.MsTest.csproj new file mode 100644 index 0000000..9e5ca09 --- /dev/null +++ b/UrlboxSDK.MsTest/UrlboxSDK.MsTest.csproj @@ -0,0 +1,23 @@ + + + + net6.0 + + false + af903291-8a0e-4c45-a74c-59122b38f976 + + $(MSBuildProjectDirectory)/Test.runsettings + + + + + + + + + + + + + + diff --git a/UrlboxSDK.MsTest/UrlboxTest.cs b/UrlboxSDK.MsTest/UrlboxTest.cs new file mode 100644 index 0000000..bdf6133 --- /dev/null +++ b/UrlboxSDK.MsTest/UrlboxTest.cs @@ -0,0 +1,1614 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Response.Resource; +using UrlboxSDK.Metadata.Resource; +using UrlboxSDK.Factory; +using System.Net.Http; +using System.Net; +using System.Collections.Generic; +using UrlboxSDK.Exception; +using UrlboxSDK.MsTest.Utils; +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK.MsTest; + +[TestClass] +public class UrlTests +{ + readonly UrlboxOptions urlboxAllOptions = new(url: "https://urlbox.com") + { + Width = 123, + Height = 123, + FullPage = true, + Selector = "test", + Clip = "test", + Gpu = true, + ResponseType = ResponseType.Json, + BlockAds = true, + HideCookieBanners = true, + ClickAccept = true, + BlockUrls = new string[] { "test", "test2" }, + BlockImages = true, + BlockFonts = true, + BlockMedias = true, + BlockStyles = true, + BlockScripts = true, + BlockFrames = true, + BlockFetch = true, + BlockXhr = true, + BlockSockets = true, + HideSelector = "test", + Js = "test", + Css = "test", + DarkMode = true, + ReducedMotion = true, + Retina = true, + ThumbWidth = 123, + ThumbHeight = 123, + ImgFit = ImgFit.Contain, + ImgPosition = ImgPosition.Northeast, + ImgBg = "test", + ImgPad = "12,10,10,10", + Quality = 100, + Transparent = true, + MaxHeight = 123, + Download = "test", + PdfPageSize = PdfPageSize.Tabloid, + PdfPageRange = "test", + PdfPageWidth = 123, + PdfPageHeight = 123, + PdfMargin = PdfMargin.Default, + PdfMarginTop = 123, + PdfMarginRight = 123, + PdfMarginBottom = 123, + PdfMarginLeft = 123, + PdfAutoCrop = true, + PdfScale = 0.12, + PdfOrientation = PdfOrientation.Portrait, + PdfBackground = true, + DisableLigatures = true, + Media = Media.Print, + PdfShowHeader = true, + PdfHeader = "test", + PdfShowFooter = true, + PdfFooter = "test", + Readable = true, + Force = true, + Unique = "test", + Ttl = 123, + Proxy = "test", + Header = new string[] { "test" }, + Cookie = new string[] { "test" }, + UserAgent = "test", + Platform = "Linux x86_64", + AcceptLang = "test", + Authorization = "test", + Tz = "test", + EngineVersion = EngineVersion.Latest, + Delay = 123, + Timeout = 123, + WaitUntil = WaitUntil.Domloaded, + WaitFor = "test", + WaitToLeave = "test", + WaitTimeout = 123, + FailIfSelectorMissing = true, + FailIfSelectorPresent = true, + FailOn4Xx = true, + FailOn5Xx = true, + ScrollTo = "test", + Click = new string[] { "test" }, + ClickAll = new string[] { "test" }, + Hover = new string[] { "test" }, + BgColor = "test", + DisableJs = true, + FullPageMode = FullPageMode.Stitch, + FullWidth = true, + AllowInfinite = true, + SkipScroll = true, + DetectFullHeight = true, + MaxSectionHeight = 123, + ScrollIncrement = 400, + ScrollDelay = 123, + Highlight = "test", + Highlightfg = "test", + Highlightbg = "test", + Latitude = 0.12, + Longitude = 0.12, + Accuracy = 123, + UseS3 = true, + S3Path = "test", + S3Bucket = "test", + S3Endpoint = "test", + S3Region = "test", + CdnHost = "test", + S3Storageclass = S3Storageclass.Standard, + WebhookUrl = "https://an-ngrok-endpoint" + }; + private Urlbox urlbox; + private RenderLinkFactory renderLinkFactory; + private MockHttpClientFixture client; + + [TestInitialize] + public void TestInitialize() + { + client = new MockHttpClientFixture(); + string key = "MY_API_KEY"; + string secret = "secret"; + + renderLinkFactory = new RenderLinkFactory(key, secret); + UrlboxConfig config = new() + { + Key = key, + Secret = secret, + WebhookSecret = "webhook_secret", + }; + urlbox = new(config, renderLinkFactory: renderLinkFactory, httpClient: client.HttpClient); + } + + + [TestMethod] + public void GenerateRenderLink_WithAllOptions() + { + string output = urlbox.GenerateRenderLink(urlboxAllOptions); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/e1e3a97a2ba637fe8423d2ad5162c6a0a0f92e46/png?accept_lang=test&accuracy=123&allow_infinite=true&authorization=test&bg_color=test&block_ads=true&block_fetch=true&block_fonts=true&block_frames=true&block_images=true&block_medias=true&block_scripts=true&block_sockets=true&block_styles=true&block_urls=test%2Ctest2&block_xhr=true&cdn_host=test&click=test&click_accept=true&click_all=test&clip=test&cookie=test&css=test&dark_mode=true&delay=123&detect_full_height=true&disable_js=true&disable_ligatures=true&download=test&engine_version=latest&fail_if_selector_missing=true&fail_if_selector_present=true&fail_on_4xx=true&fail_on_5xx=true&force=true&full_page=true&full_page_mode=stitch&full_width=true&gpu=true&header=test&height=123&hide_cookie_banners=true&hide_selector=test&highlight=test&highlight_bg=test&highlight_fg=test&hover=test&img_bg=test&img_fit=contain&img_pad=12%2C10%2C10%2C10&img_position=northeast&js=test&latitude=0.12&longitude=0.12&max_height=123&max_section_height=123&media=print&pdf_auto_crop=true&pdf_background=true&pdf_footer=test&pdf_header=test&pdf_margin=default&pdf_margin_bottom=123&pdf_margin_left=123&pdf_margin_right=123&pdf_margin_top=123&pdf_orientation=portrait&pdf_page_height=123&pdf_page_range=test&pdf_page_size=tabloid&pdf_page_width=123&pdf_scale=0.12&pdf_show_footer=true&pdf_show_header=true&platform=Linux%20x86_64&proxy=test&quality=100&readable=true&reduced_motion=true&response_type=json&retina=true&s3_bucket=test&s3_endpoint=test&s3_path=test&s3_region=test&s3_storage_class=standard&scroll_delay=123&scroll_increment=400&scroll_to=test&selector=test&skip_scroll=true&thumb_height=123&thumb_width=123&timeout=123&transparent=true&ttl=123&tz=test&unique=test&url=https%3A%2F%2Furlbox.com&user_agent=test&use_s3=true&wait_for=test&wait_timeout=123&wait_to_leave=test&wait_until=domloaded&webhook_url=https%3A%2F%2Fan-ngrok-endpoint&width=123", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_TestFormatKey_withFailOnKeys() + { + string output = urlbox.GenerateRenderLink( + Urlbox.Options(url: "testUrl").FailOn4xx().FailOn5xx().Build() + ); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/cc8ed4457a46584b7c11d964032135d05821b9b8/png?fail_on_4xx=true&fail_on_5xx=true&url=testUrl", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_withMultipleCookies() + { + UrlboxOptions options = new(url: "https://urlbox.com") + { + Cookie = new string[] { + "some=cookie", + "some=otherCookie", + "some=thirdCookie" + } + }; + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/08447cc10b0739eb755de5c5590e4bf725722c62/png?cookie=some%3Dcookie%2Csome%3DotherCookie%2Csome%3DthirdCookie&url=https%3A%2F%2Furlbox.com", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_withOneCookie() + { + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Cookie("some=cookie").Build(); + + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/d451c1b746547f68a8d2a996f2d19352711a5af6/png?cookie=some%3Dcookie&url=https%3A%2F%2Furlbox.com", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_withMultipleBlockUrls() + { + UrlboxOptions options = new(url: "https://shopify.com") + { + BlockUrls = new string[] { "cdn.shopify.com", "otherDomain" } + }; + + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/66515f594cc06af0ee6db740ef4aee4ea8bc28b7/png?block_urls=cdn.shopify.com%2CotherDomain&url=https%3A%2F%2Fshopify.com", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_withOneBlockUrl() + { + UrlboxOptions options = new(url: "https://shopify.com") + { + BlockUrls = new string[] { "cdn.shopify.com" } + }; + + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual( + "https://api.urlbox.com/v1/MY_API_KEY/fb73b41789c34999db7a747f15fad71e9d2d6b35/png?block_urls=cdn.shopify.com&url=https%3A%2F%2Fshopify.com", + output + ); + } + + [TestMethod] + public void GenerateRenderLink_WithUrlEncodedOptions() + { + UrlboxOptions options = new(url: "urlbox.com") + { + Width = 1280, + ThumbWidth = 500, + FullPage = true, + UserAgent = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36" + }; + + string output = urlbox.GenerateRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/e7f3b402debceb373e8824ef05fc5bd11fd1c1ab/png?full_page=true&thumb_width=500&url=urlbox.com&user_agent=Mozilla%2F5.0%20%28Windows%20NT%206.1%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F41.0.2228.0%20Safari%2F537.36&width=1280", + output); + } + + [TestMethod] + public void GenerateRenderLink_UrlNeedsEncoding() + { + UrlboxOptions options = new(url: "https://www.hatchtank.io/markup/index.html?url2png=true&board=demo_1645_1430"); + string output = urlbox.GenerateRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/4b8ac501f3aaccbea2081a7105302593174ebc23/png?url=https%3A%2F%2Fwww.hatchtank.io%2Fmarkup%2Findex.html%3Furl2png%3Dtrue%26board%3Ddemo_1645_1430", + output, "Not OK"); + } + + [TestMethod] + public void GenerateRenderLink_WithUserAgent() + { + UrlboxOptions options = new(url: "https://bbc.co.uk") + { + UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36" + }; + + string output = urlbox.GenerateRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/c2708392a4d881b4816e61b3ed4d89ae4f2c4a57/png?url=https%3A%2F%2Fbbc.co.uk&user_agent=Mozilla%2F5.0%20%28Macintosh%3B%20Intel%20Mac%20OS%20X%2010_12_6%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F62.0.3202.94%20Safari%2F537.36", output); + } + + [TestMethod] + public void GenerateRenderLink_IgnoreEmptyValuesAndFormat() + { + UrlboxOptions options = new(url: "https://bbc.co.uk") + { + FullPage = false, + ThumbWidth = 0, + Delay = 0, + Format = Format.Pdf, + Selector = "", + WaitFor = "", + BlockUrls = new string[] { "" }, + Cookie = new string[] { "" }, + }; + + string output = urlbox.GenerateRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/8e00ad9a8d7c4abcd462a9b8ec041c3661f13995/pdf?url=https%3A%2F%2Fbbc.co.uk", + output); + } + + [TestMethod] + public void GenerateSignedRenderLink_Succeeds() + { + UrlboxOptions options = Urlbox.Options(url: "https://bbc.co.uk").Build(); + options.Format = Format.Jpeg; + string output = urlbox.GenerateSignedRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/8e00ad9a8d7c4abcd462a9b8ec041c3661f13995/jpeg?url=https%3A%2F%2Fbbc.co.uk", output, "Not OK!"); + } + + [TestMethod] + public void GenerateRenderLink_FormatWorks() + { + UrlboxOptions options = Urlbox.Options(url: "https://bbc.co.uk").Format(Format.Avif).Build(); + string output = urlbox.GenerateRenderLink(options); + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/8e00ad9a8d7c4abcd462a9b8ec041c3661f13995/avif?url=https%3A%2F%2Fbbc.co.uk", output, "Not OK!"); + } + + [TestMethod] + public void GenerateRenderLink_WithHtml() + { + UrlboxOptions options = Urlbox.Options(html: "

test

").FullPage().Build(); + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/931010e45a7936be4a6bc208e4ef0675fd216832/png?full_page=true&html=%3Ch1%3Etest%3C%2Fh1%3E", output); + } + + [TestMethod] + public void GenerateRenderLink_WithSimpleURL() + { + UrlboxOptions options = new(url: "bbc.co.uk"); + string output = urlbox.GenerateRenderLink(options); + + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/75c9016e7f98f90f5eabfd348f3091f7bf625153/png?url=bbc.co.uk", + output, "Not OK"); + } + + [TestMethod] + public void GenerateRenderLink_ShouldRemoveFormatFromQueryString() + { + UrlboxOptions options = new(url: "https://urlbox.com") + { + Format = Format.Png, + FullPage = true + }; + string output = renderLinkFactory.GenerateRenderLink(Urlbox.BASE_URL, options); + + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/855d8a6d2d3a1ec3879860fac320005feb3df0bc/png?full_page=true&url=https%3A%2F%2Furlbox.com", output); + } + + [TestMethod] + public void GeneratePdfUrl_succeeds() + { + UrlboxOptions options = new(url: "https://urlbox.com"); + + string output = urlbox.GeneratePDFUrl(options); + + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/1322f8355419c03be28cfc18191d647a055bc73c/pdf?url=https%3A%2F%2Furlbox.com", output); + } + + [TestMethod] + public void GeneratePngUrl_succeeds() + { + UrlboxOptions options = new(url: "https://urlbox.com"); + + string output = urlbox.GeneratePNGUrl(options); + + Assert.AreEqual("https://api.urlbox.com/v1/MY_API_KEY/1322f8355419c03be28cfc18191d647a055bc73c/png?url=https%3A%2F%2Furlbox.com", output); + } + + [TestMethod] + public async Task RenderSync_Succeeds() + { + string expectedResponse = @" + { + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456, + ""htmlUrl"": ""https://example.com/screenshot.html"", + ""mhtmlUrl"": ""https://example.com/screenshot.mhtml"", + ""metadataUrl"": ""https://example.com/metadata.json"", + ""markdownUrl"": ""https://example.com/screenshot.md"", + ""metadata"": { + ""urlRequested"": ""https://example.com"", + ""urlResolved"": ""https://example.com"", + ""url"": ""https://example.com"" + } + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/sync", + (HttpStatusCode)200, + expectedResponse + ); + + UrlboxOptions options = new(url: "https://urlbox.com") { ClickAccept = true }; + + SyncUrlboxResponse result = await urlbox.Render(options); + + Assert.IsInstanceOfType(result, typeof(SyncUrlboxResponse)); + Assert.AreEqual(result.Size, 123456); + Assert.AreEqual(result.RenderUrl, "https://example.com/screenshot.png"); + + Assert.AreEqual(result.HtmlUrl, "https://example.com/screenshot.html"); + Assert.AreEqual(result.MhtmlUrl, "https://example.com/screenshot.mhtml"); + Assert.AreEqual(result.MetadataUrl, "https://example.com/metadata.json"); + Assert.AreEqual(result.MarkdownUrl, "https://example.com/screenshot.md"); + + Assert.IsNotNull(result.Metadata); + Assert.AreEqual(result.Metadata.UrlRequested, "https://example.com"); + Assert.AreEqual(result.Metadata.UrlResolved, "https://example.com"); + Assert.AreEqual(result.Metadata.Url, "https://example.com"); + } + + [TestMethod] + public async Task RenderSync_Dictionary_Succeeds() + { + string expectedResponse = @" + { + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456, + ""htmlUrl"": ""https://example.com/screenshot.html"", + ""mhtmlUrl"": ""https://example.com/screenshot.mhtml"", + ""metadataUrl"": ""https://example.com/metadata.json"", + ""markdownUrl"": ""https://example.com/screenshot.md"", + ""metadata"": { + ""urlRequested"": ""https://example.com"", + ""urlResolved"": ""https://example.com"", + ""url"": ""https://example.com"" + } + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/sync", + (HttpStatusCode)200, + expectedResponse + ); + + + IDictionary options = new Dictionary + { + { "click_accept", true }, + { "url", "https://urlbox.com" } + }; + + SyncUrlboxResponse result = await urlbox.Render(options); + + Assert.IsInstanceOfType(result, typeof(SyncUrlboxResponse)); + Assert.AreEqual(result.RenderUrl, "https://example.com/screenshot.png"); + Assert.AreEqual(result.Size, 123456); + + Assert.AreEqual(result.HtmlUrl, "https://example.com/screenshot.html"); + Assert.AreEqual(result.MhtmlUrl, "https://example.com/screenshot.mhtml"); + Assert.AreEqual(result.MetadataUrl, "https://example.com/metadata.json"); + Assert.AreEqual(result.MarkdownUrl, "https://example.com/screenshot.md"); + + Assert.IsNotNull(result.Metadata); + Assert.AreEqual(result.Metadata.UrlRequested, "https://example.com"); + Assert.AreEqual(result.Metadata.UrlResolved, "https://example.com"); + Assert.AreEqual(result.Metadata.Url, "https://example.com"); + } + + [TestMethod] + public async Task RenderAsync_Succeeds() + { + string expectedResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + expectedResponse + ); + + + UrlboxOptions options = new(url: "https://urlbox.com") + { + ClickAccept = true + }; + + AsyncUrlboxResponse result = await urlbox.RenderAsync(options); + + Assert.IsInstanceOfType(result, typeof(AsyncUrlboxResponse)); + Assert.IsNotNull(result.Status); + Assert.IsNotNull(result.RenderId); + Assert.IsNotNull(result.StatusUrl); + + Assert.AreEqual("created", result.Status, "Render Async Failed"); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/status", result.StatusUrl); + + Assert.IsNull(result.RenderUrl); + Assert.IsNull(result.HtmlUrl); + Assert.IsNull(result.MhtmlUrl); + Assert.IsNull(result.MarkdownUrl); + Assert.IsNull(result.MetadataUrl); + Assert.IsNull(result.Metadata); + Assert.IsNull(result.Size); + } + + [TestMethod] + public async Task RenderAsync_Dictionary_Succeeds() + { + string expectedResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + expectedResponse + ); + + + IDictionary options = new Dictionary + { + { "click_accept", true }, + { "url", "https://urlbox.com" } + }; + + AsyncUrlboxResponse result = await urlbox.RenderAsync(options); + + Assert.IsInstanceOfType(result, typeof(AsyncUrlboxResponse)); + Assert.IsNotNull(result.Status); + Assert.IsNotNull(result.RenderId); + Assert.IsNotNull(result.StatusUrl); + + Assert.AreEqual("created", result.Status, "Render Async Failed"); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/status", result.StatusUrl); + + // Should be null as not succeeded yet + Assert.IsNull(result.RenderUrl); + Assert.IsNull(result.HtmlUrl); + Assert.IsNull(result.MhtmlUrl); + Assert.IsNull(result.MarkdownUrl); + Assert.IsNull(result.MetadataUrl); + Assert.IsNull(result.Metadata); + Assert.IsNull(result.Size); + } + + [TestMethod] + public async Task Render_ThrowsException() + { + string errorResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors - {\""url\"":[\""error resolving URL - ENOTFOUND fakesite.com\""]}"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND fakesite.com\""]}"" + }, + ""requestId"": ""7be80323-3b75-4cf1-960f-13e9f3ff404c"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/sync", + (HttpStatusCode)400, + errorResponse + ); + + + UrlboxOptions options = new(url: "https://fakesite.com"); + + UrlboxException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.Render(options) + ); + + Assert.IsTrue(exception.Message.Contains("Invalid options, please check errors -")); + Assert.AreEqual("InvalidOptions", exception.Code); + Assert.IsNotNull(exception.Errors); + Assert.IsTrue(exception.Errors.Contains("error resolving URL - ENOTFOUND fakesite.com")); + } + + [TestMethod] + public async Task Render_ThrowsException_wrongResponseType() + { + UrlboxOptions options = new(url: "https://fakesite.com") + { + ResponseType = ResponseType.Binary + }; + + ArgumentException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.Render(options) + ); + + Assert.AreEqual("Response type must be Json when using POST methods in this SDK.", exception.Message); + } + + [TestMethod] + public async Task Render_Dictionary_ThrowsException() + { + string errorResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors - {\""url\"":[\""error resolving URL - ENOTFOUND fakeSite.com\""]}"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND fakeSite.com\""]}"" + }, + ""requestId"": ""7be80323-3b75-4cf1-960f-13e9f3ff404c"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/sync", + (HttpStatusCode)400, + errorResponse + ); + + + IDictionary options = new Dictionary + { + { "url", "https://fakesite.com" } + }; + + UrlboxException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.Render(options) + ); + + Assert.IsTrue(exception.Message.Contains("Invalid options, please check errors -")); + Assert.AreEqual("InvalidOptions", exception.Code); + Assert.IsNotNull(exception.Errors); + Assert.IsTrue(exception.Errors.Contains("error resolving URL - ENOTFOUND fakeSite.com")); + } + + [TestMethod] + public async Task RenderAsync_ThrowsException() + { + string errorResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors - {\""url\"":[\""error resolving URL - ENOTFOUND fakeSite.com\""]}"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND fakeSite.com\""]}"" + }, + ""requestId"": ""7be80323-3b75-4cf1-960f-13e9f3ff404c"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)400, + errorResponse + ); + + + UrlboxOptions options = new(url: "https://fakesite.com"); + + UrlboxException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.RenderAsync(options) + ); + + Assert.IsTrue(exception.Message.Contains("Invalid options, please check errors -")); + Assert.AreEqual("InvalidOptions", exception.Code); + Assert.IsNotNull(exception.Errors); + Assert.IsTrue(exception.Errors.Contains("error resolving URL - ENOTFOUND fakeSite.com")); + } + + [TestMethod] + public async Task RenderAsync_Dictionary_ThrowsException() + { + string errorResponse = @" + { + ""error"": { + ""message"": ""Invalid options, please check errors - {\""url\"":[\""error resolving URL - ENOTFOUND fakesite.com\""]}"", + ""code"": ""InvalidOptions"", + ""errors"": ""{\""url\"":[\""error resolving URL - ENOTFOUND fakesite.com\""]}"" + }, + ""requestId"": ""7be80323-3b75-4cf1-960f-13e9f3ff404c"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)400, + errorResponse + ); + + + IDictionary options = new Dictionary + { + { "url", "https://fakesite.com" } + }; + + UrlboxException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.RenderAsync(options) + ); + + Assert.IsTrue(exception.Message.Contains("Invalid options, please check errors -")); + Assert.AreEqual("InvalidOptions", exception.Code); + Assert.IsNotNull(exception.Errors); + Assert.IsTrue(exception.Errors.Contains("error resolving URL - ENOTFOUND fakesite.com")); + } + + + [TestMethod] + public async Task TakeScreenshot_Succeeds() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + + UrlboxOptions options = new(url: "https://urlbox.com") + { + Height = 125, + Width = 125 + }; + + AsyncUrlboxResponse result = await urlbox.TakeScreenshot(options); + + Assert.IsNotNull(result); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/screenshot.png", result.RenderUrl); + Assert.AreEqual(123456, result.Size); + Assert.AreEqual("succeeded", result.Status); + } + + [TestMethod] + public async Task TakeScreenshot_SucceedsWithLargerTimeout() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + + UrlboxOptions options = new(url: "https://urlbox.com") + { + Height = 125, + Width = 125 + }; + + // Use a larger timeout value + AsyncUrlboxResponse result = await urlbox.TakeScreenshot(options, 120000); + + Assert.IsNotNull(result); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/screenshot.png", result.RenderUrl); + Assert.AreEqual(123456, result.Size); + Assert.AreEqual("succeeded", result.Status); + } + + [TestMethod] + public async Task TakeScreenshot_TimeoutTooLarge() + { + UrlboxOptions options = new(url: "https://urlbox.com") + { + Height = 125, + Width = 125, + }; + + TimeoutException result = await Assert.ThrowsExceptionAsync(() => urlbox.TakeScreenshot(options, 1200001)); + Assert.AreEqual("Invalid Timeout Length. Must be between 5000 (5 seconds) and 120000 (2 minutes).", result.Message); + } + + [TestMethod] + public async Task TakeScreenshot_TimeoutTooSmall() + { + UrlboxOptions options = new(url: "https://urlbox.com") + { + Height = 125, + Width = 125, + }; + + TimeoutException result = await Assert.ThrowsExceptionAsync(() => urlbox.TakeScreenshot(options, 4999)); + Assert.AreEqual("Invalid Timeout Length. Must be between 5000 (5 seconds) and 120000 (2 minutes).", result.Message); + } + + [TestMethod] + public async Task TakeMp4_Succeeds() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.mp4"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + AsyncUrlboxResponse result = await urlbox.TakeMp4(options); + + Assert.IsNotNull(result); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/screenshot.mp4", result.RenderUrl); + Assert.AreEqual(123456, result.Size); + } + + [TestMethod] + public async Task TakePdf_Succeeds() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.pdf"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + AsyncUrlboxResponse result = await urlbox.TakePdf(options); + + Assert.IsNotNull(result); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/screenshot.pdf", result.RenderUrl); + Assert.AreEqual(123456, result.Size); + } + + [TestMethod] + public async Task TakeMetadata_Succeeds() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456, + ""metadata"": { + ""urlRequested"": ""https://urlbox.com"", + ""url"": ""https://urlbox.com"", + ""urlResolved"": ""https://example.com"", + ""title"": ""Example Title"" + } + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + + UrlboxOptions options = new(url: "https://urlbox.com"); + + AsyncUrlboxResponse result = await urlbox.TakeScreenshotWithMetadata(options); + + Assert.IsNotNull(result); + Assert.AreEqual("abc123", result.RenderId); + Assert.AreEqual("https://example.com/screenshot.png", result.RenderUrl); + Assert.AreEqual(123456, result.Size); + Assert.IsNotNull(result.Metadata); + Assert.AreEqual("https://urlbox.com", result.Metadata.UrlRequested); + Assert.AreEqual("https://example.com", result.Metadata.UrlResolved); + Assert.AreEqual("Example Title", result.Metadata.Title); + } + + [TestMethod] + public async Task GetStatus_succeeds() + { + string renderId = "ca482d7e-9417-4569-90fe-80f7c5e1c781"; + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""ca482d7e-9417-4569-90fe-80f7c5e1c781"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456, + ""metadata"": { + ""urlRequested"": ""https://urlbox.com"", + ""url"": ""https://urlbox.com"", + ""urlResolved"": ""https://example.com"", + ""title"": ""Example Title"" + } + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/ca482d7e-9417-4569-90fe-80f7c5e1c781", + (HttpStatusCode)200, + statusResponse + ); + + AsyncUrlboxResponse status = await urlbox.GetStatus(renderId); + + Assert.AreEqual(status.RenderId, renderId); + Assert.IsNotNull(status.Status); + Assert.AreEqual(status.Status, "succeeded"); + } + + [TestMethod] + public async Task GetStatus_fails() + { + string renderId = "ca482d7e-9417-4569-90fe-80f7c5e1c781"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/{renderId}", + (HttpStatusCode)500, + "" // No response body or error headers + ); + + ArgumentException exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.GetStatus(renderId) + ); + + Assert.AreEqual( + "Failed to check status of async request: Request failed: No x-urlbox-error-message header found", + exception.Message + ); + } + + [TestMethod] + public async Task DownloadToFile_succeeds_overload() + { + string urlboxUrl = "https://api.urlbox.com/v1/MY_API_KEY/png?url=https%3A%2F%2Furlbox.com"; + + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + (HttpStatusCode)200, + "somebuffer" + ); + + string result = await urlbox.DownloadToFile(options, "filename", sign: false); + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(String)); + Assert.IsTrue(result.Length >= 0); + } + + [TestMethod] + public async Task DownloadToFile_succeeds_overload_jsonResponseType() + { + // Should be this endpoint that is hit instead of with response_type=json + string urlboxUrl = "https://api.urlbox.com/v1/MY_API_KEY/png?response_type=base64&url=https%3A%2F%2Furlbox.com"; + + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + options.ResponseType = ResponseType.Json; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + (HttpStatusCode)200, + "somebuffer" + ); + + string result = await urlbox.DownloadToFile(options, "somefile", sign: false); + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(String)); + Assert.IsTrue(result.Length >= 0); + } + + [TestMethod] + public async Task DownloadToFile_succeeds() + { + string urlboxUrl = "https://api.urlbox.com/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/5ee277f206869517d00cf1951f30d48ef9c64bfe/png?url=google.com"; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + (HttpStatusCode)200, + "somebuffer" + ); + + string result = await urlbox.DownloadToFile(urlboxUrl, "result.png"); + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(String)); + Assert.IsTrue(result.Length >= 0); + } + + [TestMethod] + public async Task DownloadToFile_fails() + { + string urlboxUrl = "https://api.urlbox.com/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/5ee277f206869517d00cf1951f30d48ef9c64bfe/png?url=google.com"; + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + (HttpStatusCode)400, + "", + headers: new Dictionary + { + { "x-urlbox-error-message", "some error message from Urlbox API" } + } + ); + + System.Exception result = await Assert.ThrowsExceptionAsync(async () => await urlbox.DownloadToFile(urlboxUrl, "result.png")); + + Assert.IsNotNull(result); + Assert.AreEqual(result.Message, "Request failed: some error message from Urlbox API"); + } + + [TestMethod] + public async Task DownloadBase64_succeeds() + { + string urlboxUrl = "https://api.urlbox.com/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ace/jpeg?url=bbc.co.uk"; + string mockContent = "Test Image Content"; + + string encodedContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(mockContent)); + string expectedBase64 = "text/plain; charset=utf-8;base64," + encodedContent; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + HttpStatusCode.OK, + mockContent + ); + + string base64result = await urlbox.DownloadAsBase64(urlboxUrl); + + Assert.IsNotNull(base64result); + Assert.AreEqual(expectedBase64, base64result, "Expected the base64 string to match the mocked content."); + } + + [TestMethod] + public async Task DownloadBase64_succeeds_overload() + { + string urlboxUrl = "https://api.urlbox.com/v1/MY_API_KEY/png?url=https%3A%2F%2Furlbox.com"; + string mockContent = "Test Image Content"; + + string encodedContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(mockContent)); + string expectedBase64 = "text/plain; charset=utf-8;base64," + encodedContent; + + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + HttpStatusCode.OK, + mockContent + ); + string base64result = await urlbox.DownloadAsBase64(options, sign: false); + + Assert.IsNotNull(base64result); + Assert.AreEqual(expectedBase64, base64result, "Expected the base64 string to match the mocked content."); + } + + [TestMethod] + public async Task DownloadBase64_succeeds_overload_jsonResponse() + { + // Should reconfigure options to base64 instead + string urlboxUrl = "https://api.urlbox.com/v1/MY_API_KEY/png?response_type=base64&url=https%3A%2F%2Furlbox.com"; + string mockContent = "Test Image Content"; + + string encodedContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(mockContent)); + string expectedBase64 = "text/plain; charset=utf-8;base64," + encodedContent; + + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + options.ResponseType = ResponseType.Json; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + HttpStatusCode.OK, + mockContent + ); + string base64result = await urlbox.DownloadAsBase64(options, sign: false); + + Assert.IsNotNull(base64result); + Assert.AreEqual(expectedBase64, base64result, "Expected the base64 string to match the mocked content."); + } + + [TestMethod] + public async Task DownloadFail() + { + string urlboxUrl = "https://api.urlbox.com/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ac/jpeg?url=bbc.co.uk"; + string expectedErrorMessage = "The generated token was incorrect. Please look in the docs (https://urlbox.com/docs) for how to generate your token correctly in the language you are using. TLDR: It should be the HMAC SHA256 of your query string, *signed* by your user secret, which you can find by logging into the urlbox dashboard>. Expected the error message to match the mocked content."; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + HttpStatusCode.Unauthorized, + "", + new Dictionary + { + { "x-urlbox-error-message", expectedErrorMessage } + } + ); + + System.Exception exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.DownloadAsBase64(urlboxUrl) + ); + + Assert.IsNotNull(exception); + Assert.AreEqual("Request failed: " + expectedErrorMessage, exception.Message, "Expected the error message to match the mocked content."); + } + + [TestMethod] + public async Task DownloadFail_wrongResponseType() + { + string urlboxUrl = "https://api.urlbox.com/v1/ca482d7e-9417-4569-90fe-80f7c5e1c781/59148a4e454a2c7051488defdb8b246bdea61ac/jpeg?url=bbc.co.uk&response_type=json"; + + string mockResponse = @" + { + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + urlboxUrl, + HttpStatusCode.OK, + mockResponse, + new Dictionary + { + { "Content-Type", "application/json" } + } + ); + + System.Exception exception = await Assert.ThrowsExceptionAsync( + async () => await urlbox.DownloadAsBase64(urlboxUrl) + ); + + System.Exception exceptionFile = await Assert.ThrowsExceptionAsync( + async () => await urlbox.DownloadToFile(urlboxUrl, "filename") + ); + + Assert.IsNotNull(exception); + Assert.IsNotNull(exceptionFile); + Assert.AreEqual("Please use Response Type of Binary when downloading a render link.", exception.Message, "Expected the error message to match the mocked content."); + Assert.AreEqual("Please use Response Type of Binary when downloading a render link.", exceptionFile.Message, "Expected the error message to match the mocked content."); + } + + [TestMethod] + public async Task ExtractMetadata() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456, + ""metadata"": { + ""urlRequested"": ""https://urlbox.com"", + ""url"": ""https://urlbox.com"", + ""urlResolved"": ""https://example.com"", + ""title"": ""Example Title"" + } + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + UrlboxMetadata result = await urlbox.ExtractMetadata(options); + + Assert.IsNotNull(result); + Assert.AreEqual("https://urlbox.com", result.UrlRequested); + Assert.AreEqual("https://example.com", result.UrlResolved); + Assert.AreEqual("Example Title", result.Title); + } + + [TestMethod] + public async Task ExtractMetadata_Throws() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://example.com/status"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://example.com/screenshot.png"", + ""size"": 123456 + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + await Assert.ThrowsExceptionAsync(async () => await urlbox.ExtractMetadata(options)); + } + + [TestMethod] + public async Task ExtractMarkdown() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://renders.urlbox.com/screenshot.md"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string base64Md = "text/markdown; charset=utf-8;base64,W0Fib3V0XVsxXVtTdG9yZV1bMl0KCltHbWFpbF1bM10KCltJbWFnZXNdWzRdCgpbXVs1XQoKW1NpZ24gaW5dWzZdCgpbU2Vhc29uYWwgSG9saWRheXMgMjAyNF0KCiAgCgpDaG9vc2Ugd2hhdCB5b3XigJlyZSBnaXZpbmcgZmVlZGJhY2sgb24KCi0gICBbXQogICAgCiAgICBbXVtdCiAgICAKICAgIFNlZSBtb3JlCiAgICAKICAgIERlbGV0ZQogICAgCi0gICBbXQogICAgCiAgICBbXVtdCiAgICAKLSAgIERlbGV0ZQogICAgCgpbXQoKW11bXQoKIAoKUmVwb3J0IGluYXBwcm9wcmlhdGUgcHJlZGljdGlvbnMKCiAKCkknbSBGZWVsaW5nIEN1cmlvdXMKCkknbSBGZWVsaW5nIEh1bmdyeQoKSSdtIEZlZWxpbmcgQWR2ZW50dXJvdXMKCkknbSBGZWVsaW5nIFBsYXlmdWwKCkknbSBGZWVsaW5nIFN0ZWxsYXIKCkknbSBGZWVsaW5nIERvb2RsZXkKCkknbSBGZWVsaW5nIFRyZW5keQoKSSdtIEZlZWxpbmcgQXJ0aXN0aWMKCkknbSBGZWVsaW5nIEZ1bm55CgpDYW4ndCBhZGQuIFVzZSBhIFBERiBGaWxlIHVuZGVyIDIwME1CIHRvIGFzayBhIHF1ZXN0aW9uLgoKICAKCltBZHZlcnRpc2luZ11bN11bQnVzaW5lc3NdWzhdIFtIb3cgU2VhcmNoIHdvcmtzXVs5XQoKW1tnb2xYS2hNczVYcWEweFUxbHlvYTJmWEZ5UU9zREczOHFzTHk0VGFWK3NGaXNsb3Z5aFB6TEpKckJ1NmVRT3RwVzBMamJKa3pUdVRETFJWTkthM3V4SkkrVmRpUnFYU2V1NkdXK1F4aTI5ZUxJaThIN0VzWXJUNDJCRCttUXROTzVKTWpSdUM0bFNZOFY0aHNMWDBlZ0dpanZVU0VQOUFieWxFc09rZUNnV0FBQUFBRWxGVGtTdVFtQ0NdT3VyIHRoaXJkIGRlY2FkZSBvZiBjbGltYXRlIGFjdGlvbjogam9pbiB1c11bMTBdCgpbUHJpdmFjeV1bMTFdW1Rlcm1zXVsxMl0KClNldHRpbmdzCgpbU2VhcmNoIHNldHRpbmdzXVsxM10KCltBZHZhbmNlZCBzZWFyY2hdWzE0XQoKW1lvdXIgZGF0YSBpbiBTZWFyY2hdWzE1XQoKW1NlYXJjaCBoaXN0b3J5XVsxNl0KCltTZWFyY2ggaGVscF1bMTddCgpTZW5kIGZlZWRiYWNrCgpEYXJrIHRoZW1lOiBPZmYKCkdvb2dsZSBhcHBzCgpbMV06IGh0dHBzOi8vYWJvdXQuZ29vZ2xlLz9mZz0xJnV0bV9zb3VyY2U9Z29vZ2xlLVVTJnV0bV9tZWRpdW09cmVmZXJyYWwmdXRtX2NhbXBhaWduPWhwLWhlYWRlcgpbMl06IGh0dHBzOi8vc3RvcmUuZ29vZ2xlLmNvbS9VUz91dG1fc291cmNlPWhwX2hlYWRlciZ1dG1fbWVkaXVtPWdvb2dsZV9vb28mdXRtX2NhbXBhaWduPUdTMTAwMDQyJmhsPWVuLVVTClszXTogaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC8mb2dibApbNF06IGh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vaW1naHA/aGw9ZW4mb2dibApbNV06IGh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vaW50bC9lbi9hYm91dC9wcm9kdWN0cwpbNl06IGh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9TZXJ2aWNlTG9naW4/aGw9ZW4mcGFzc2l2ZT10cnVlJmNvbnRpbnVlPWh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vJmVjPUdBWkFtZ1EKWzddOiBodHRwczovL3d3dy5nb29nbGUuY29tL2ludGwvZW5fdXMvYWRzLz9zdWJpZD13dy13dy1ldC1nLWF3YS1hLWdfaHBhZm9vdDFfMSFvMiZ1dG1fc291cmNlPWdvb2dsZS5jb20mdXRtX21lZGl1bT1yZWZlcnJhbCZ1dG1fY2FtcGFpZ249Z29vZ2xlX2hwYWZvb3RlciZmZz0xCls4XTogaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zZXJ2aWNlcy8/c3ViaWQ9d3ctd3ctZXQtZy1hd2EtYS1nX2hwYmZvb3QxXzEhbzImdXRtX3NvdXJjZT1nb29nbGUuY29tJnV0bV9tZWRpdW09cmVmZXJyYWwmdXRtX2NhbXBhaWduPWdvb2dsZV9ocGJmb290ZXImZmc9MQpbOV06IGh0dHBzOi8vZ29vZ2xlLmNvbS9zZWFyY2gvaG93c2VhcmNod29ya3MvP2ZnPTEKWzEwXTogaHR0cHM6Ly9zdXN0YWluYWJpbGl0eS5nb29nbGUvP3V0bV9zb3VyY2U9Z29vZ2xlaHBmb290ZXImdXRtX21lZGl1bT1ob3VzZXByb21vcyZ1dG1fY2FtcGFpZ249Ym90dG9tLWZvb3RlciZ1dG1fY29udGVudD0KWzExXTogaHR0cHM6Ly9wb2xpY2llcy5nb29nbGUuY29tL3ByaXZhY3k/aGw9ZW4mZmc9MQpbMTJdOiBodHRwczovL3BvbGljaWVzLmdvb2dsZS5jb20vdGVybXM/aGw9ZW4mZmc9MQpbMTNdOiBodHRwczovL3d3dy5nb29nbGUuY29tL3ByZWZlcmVuY2VzP2hsPWVuJmZnPTEKWzE0XTogL2FkdmFuY2VkX3NlYXJjaD9obD1lbiZmZz0xClsxNV06IC9oaXN0b3J5L3ByaXZhY3lhZHZpc29yL3NlYXJjaC91bmF1dGg/dXRtX3NvdXJjZT1nb29nbGVtZW51JmZnPTEmY2N0bGQ9Y29tClsxNl06IC9oaXN0b3J5L29wdG91dD9obD1lbiZmZz0xClsxN106IGh0dHBzOi8vc3VwcG9ydC5nb29nbGUuY29tL3dlYnNlYXJjaC8/cD13c19yZXN1bHRzX2hlbHAmaGw9ZW4mZmc9MQ=="; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.md", + (HttpStatusCode)200, + base64Md + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + string result = await urlbox.ExtractMarkdown(options); + + Assert.IsNotNull(result); + Assert.AreEqual(base64Md, result); + } + + [TestMethod] + public async Task ExtractMarkdown_result_null_throws() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string base64Md = "text/markdown; charset=utf-8;base64,W0Fib3V0XVsxXVtTdG9yZV1bMl0KCltHbWFpbF1bM10KCltJbWFnZXNdWzRdCgpbXVs1XQoKW1NpZ24gaW5dWzZdCgpbU2Vhc29uYWwgSG9saWRheXMgMjAyNF0KCiAgCgpDaG9vc2Ugd2hhdCB5b3XigJlyZSBnaXZpbmcgZmVlZGJhY2sgb24KCi0gICBbXQogICAgCiAgICBbXVtdCiAgICAKICAgIFNlZSBtb3JlCiAgICAKICAgIERlbGV0ZQogICAgCi0gICBbXQogICAgCiAgICBbXVtdCiAgICAKLSAgIERlbGV0ZQogICAgCgpbXQoKW11bXQoKIAoKUmVwb3J0IGluYXBwcm9wcmlhdGUgcHJlZGljdGlvbnMKCiAKCkknbSBGZWVsaW5nIEN1cmlvdXMKCkknbSBGZWVsaW5nIEh1bmdyeQoKSSdtIEZlZWxpbmcgQWR2ZW50dXJvdXMKCkknbSBGZWVsaW5nIFBsYXlmdWwKCkknbSBGZWVsaW5nIFN0ZWxsYXIKCkknbSBGZWVsaW5nIERvb2RsZXkKCkknbSBGZWVsaW5nIFRyZW5keQoKSSdtIEZlZWxpbmcgQXJ0aXN0aWMKCkknbSBGZWVsaW5nIEZ1bm55CgpDYW4ndCBhZGQuIFVzZSBhIFBERiBGaWxlIHVuZGVyIDIwME1CIHRvIGFzayBhIHF1ZXN0aW9uLgoKICAKCltBZHZlcnRpc2luZ11bN11bQnVzaW5lc3NdWzhdIFtIb3cgU2VhcmNoIHdvcmtzXVs5XQoKW1tnb2xYS2hNczVYcWEweFUxbHlvYTJmWEZ5UU9zREczOHFzTHk0VGFWK3NGaXNsb3Z5aFB6TEpKckJ1NmVRT3RwVzBMamJKa3pUdVRETFJWTkthM3V4SkkrVmRpUnFYU2V1NkdXK1F4aTI5ZUxJaThIN0VzWXJUNDJCRCttUXROTzVKTWpSdUM0bFNZOFY0aHNMWDBlZ0dpanZVU0VQOUFieWxFc09rZUNnV0FBQUFBRWxGVGtTdVFtQ0NdT3VyIHRoaXJkIGRlY2FkZSBvZiBjbGltYXRlIGFjdGlvbjogam9pbiB1c11bMTBdCgpbUHJpdmFjeV1bMTFdW1Rlcm1zXVsxMl0KClNldHRpbmdzCgpbU2VhcmNoIHNldHRpbmdzXVsxM10KCltBZHZhbmNlZCBzZWFyY2hdWzE0XQoKW1lvdXIgZGF0YSBpbiBTZWFyY2hdWzE1XQoKW1NlYXJjaCBoaXN0b3J5XVsxNl0KCltTZWFyY2ggaGVscF1bMTddCgpTZW5kIGZlZWRiYWNrCgpEYXJrIHRoZW1lOiBPZmYKCkdvb2dsZSBhcHBzCgpbMV06IGh0dHBzOi8vYWJvdXQuZ29vZ2xlLz9mZz0xJnV0bV9zb3VyY2U9Z29vZ2xlLVVTJnV0bV9tZWRpdW09cmVmZXJyYWwmdXRtX2NhbXBhaWduPWhwLWhlYWRlcgpbMl06IGh0dHBzOi8vc3RvcmUuZ29vZ2xlLmNvbS9VUz91dG1fc291cmNlPWhwX2hlYWRlciZ1dG1fbWVkaXVtPWdvb2dsZV9vb28mdXRtX2NhbXBhaWduPUdTMTAwMDQyJmhsPWVuLVVTClszXTogaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC8mb2dibApbNF06IGh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vaW1naHA/aGw9ZW4mb2dibApbNV06IGh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vaW50bC9lbi9hYm91dC9wcm9kdWN0cwpbNl06IGh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9TZXJ2aWNlTG9naW4/aGw9ZW4mcGFzc2l2ZT10cnVlJmNvbnRpbnVlPWh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vJmVjPUdBWkFtZ1EKWzddOiBodHRwczovL3d3dy5nb29nbGUuY29tL2ludGwvZW5fdXMvYWRzLz9zdWJpZD13dy13dy1ldC1nLWF3YS1hLWdfaHBhZm9vdDFfMSFvMiZ1dG1fc291cmNlPWdvb2dsZS5jb20mdXRtX21lZGl1bT1yZWZlcnJhbCZ1dG1fY2FtcGFpZ249Z29vZ2xlX2hwYWZvb3RlciZmZz0xCls4XTogaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zZXJ2aWNlcy8/c3ViaWQ9d3ctd3ctZXQtZy1hd2EtYS1nX2hwYmZvb3QxXzEhbzImdXRtX3NvdXJjZT1nb29nbGUuY29tJnV0bV9tZWRpdW09cmVmZXJyYWwmdXRtX2NhbXBhaWduPWdvb2dsZV9ocGJmb290ZXImZmc9MQpbOV06IGh0dHBzOi8vZ29vZ2xlLmNvbS9zZWFyY2gvaG93c2VhcmNod29ya3MvP2ZnPTEKWzEwXTogaHR0cHM6Ly9zdXN0YWluYWJpbGl0eS5nb29nbGUvP3V0bV9zb3VyY2U9Z29vZ2xlaHBmb290ZXImdXRtX21lZGl1bT1ob3VzZXByb21vcyZ1dG1fY2FtcGFpZ249Ym90dG9tLWZvb3RlciZ1dG1fY29udGVudD0KWzExXTogaHR0cHM6Ly9wb2xpY2llcy5nb29nbGUuY29tL3ByaXZhY3k/aGw9ZW4mZmc9MQpbMTJdOiBodHRwczovL3BvbGljaWVzLmdvb2dsZS5jb20vdGVybXM/aGw9ZW4mZmc9MQpbMTNdOiBodHRwczovL3d3dy5nb29nbGUuY29tL3ByZWZlcmVuY2VzP2hsPWVuJmZnPTEKWzE0XTogL2FkdmFuY2VkX3NlYXJjaD9obD1lbiZmZz0xClsxNV06IC9oaXN0b3J5L3ByaXZhY3lhZHZpc29yL3NlYXJjaC91bmF1dGg/dXRtX3NvdXJjZT1nb29nbGVtZW51JmZnPTEmY2N0bGQ9Y29tClsxNl06IC9oaXN0b3J5L29wdG91dD9obD1lbiZmZz0xClsxN106IGh0dHBzOi8vc3VwcG9ydC5nb29nbGUuY29tL3dlYnNlYXJjaC8/cD13c19yZXN1bHRzX2hlbHAmaGw9ZW4mZmc9MQ=="; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.md", + (HttpStatusCode)200, + base64Md + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + await Assert.ThrowsExceptionAsync(async () => await urlbox.ExtractMarkdown(options)); + } + + [TestMethod] + public async Task ExtractHtml() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://renders.urlbox.com/screenshot.html"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string html = ""; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.html", + (HttpStatusCode)200, + html + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + string result = await urlbox.ExtractHtml(options); + + Assert.IsNotNull(result); + Assert.AreEqual(html, result); + } + + [TestMethod] + public async Task ExtractHtml_throws() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string html = ""; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.html", + (HttpStatusCode)200, + html + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + await Assert.ThrowsExceptionAsync(async () => await urlbox.ExtractHtml(options)); + } + + [TestMethod] + public async Task ExtractMhtml() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"", + ""renderUrl"": ""https://renders.urlbox.com/screenshot.mhtml"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string mhtml = @" + MIME-Version: 1.0 + Content-Type: multipart/related; boundary=""----=_NextPart_000_0000"" + + ------=_NextPart_000_0000 + Content-Type: text/html; charset=""utf-8"" + Content-Transfer-Encoding: quoted-printable + + + + Sample Page + + +

Hello, World!

+ + + + + ------=_NextPart_000_0000 + Content-Type: image/jpeg + Content-Transfer-Encoding: base64 + Content-Location: image001.jpg@01D12345 + + /9j/4AAQSkZJRgABAQEAYABgAAD/2wCEABALD//2Q== + ------=_NextPart_000_0000-- + "; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.mhtml", + (HttpStatusCode)200, + mhtml + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + string result = await urlbox.ExtractMhtml(options); + + Assert.IsNotNull(result); + Assert.AreEqual(mhtml, result); + } + + [TestMethod] + public async Task ExtractMhtml_throws() + { + string initialResponse = @" + { + ""status"": ""created"", + ""renderId"": ""abc123"", + ""statusUrl"": ""https://urlbox.com"" + }"; + + client.StubRequest( + HttpMethod.Post, + Urlbox.BASE_URL + "/v1/render/async", + (HttpStatusCode)200, + initialResponse + ); + + string statusResponse = @" + { + ""status"": ""succeeded"", + ""renderId"": ""abc123"" + }"; + + client.StubRequest( + HttpMethod.Get, + $"{Urlbox.BASE_URL}/v1/render/abc123", + (HttpStatusCode)200, + statusResponse + ); + + string html = ""; + + client.StubRequest( + HttpMethod.Get, + $"https://renders.urlbox.com/screenshot.html", + (HttpStatusCode)200, + html + ); + + UrlboxOptions options = new(url: "https://urlbox.com"); + + await Assert.ThrowsExceptionAsync(async () => await urlbox.ExtractMhtml(options)); + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Utils/MockHttpClient.cs b/UrlboxSDK.MsTest/Utils/MockHttpClient.cs new file mode 100644 index 0000000..7270cd9 --- /dev/null +++ b/UrlboxSDK.MsTest/Utils/MockHttpClient.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +#nullable enable + +namespace UrlboxSDK.MsTest.Utils; + +public class MockHttpClientFixture +{ + public Mock MockHandler { get; } + public HttpClient HttpClient { get; } + + public MockHttpClientFixture() + { + // Create a mock of HttpMessageHandler + MockHandler = new Mock(MockBehavior.Strict); + + // Create a real HttpClient using the mock + HttpClient = new HttpClient(MockHandler.Object); + } + + /// + /// Sets up a mocked HTTP request using the specified method, URL, status code, and response content. + /// This method configures the mock HTTP client to return a predefined response when a matching request is sent. + /// + /// The HTTP method to match (e.g., GET, POST). + /// The exact request URL to match. + /// The HTTP status code to return (e.g., 200, 404). + /// The response content to return as the HTTP body. + /// + /// Optional dictionary of headers to include in the HTTP response. + /// Keys are header names, and values are header values. + /// + public void StubRequest(HttpMethod method, string url, HttpStatusCode status, string responseContent, Dictionary? headers = null) + { + HttpResponseMessage response = new(status) + { + Content = new StringContent(responseContent) + }; + + // Add headers if provided + if (headers != null) + { + foreach (KeyValuePair header in headers) + { + if (header.Key.Equals("Content-Type", System.StringComparison.OrdinalIgnoreCase)) + { + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(header.Value); + } + else + { + response.Headers.Add(header.Key, header.Value); + } + } + } + + MockHandler.Protected() + // Setup the protected SendAsync method of HttpMessageHandler to simulate an HTTP request + .Setup>( + "SendAsync", // The protected method being mocked + ItExpr.Is(req => + // Match the request based on HTTP method and exact request URL + req.Method == method && req.RequestUri != null && req.RequestUri.ToString() == url), + ItExpr.IsAny() // Accept any cancellation token + ) + // Return a pre-defined HttpResponseMessage when the request matches the conditions + .ReturnsAsync(response); + } +} \ No newline at end of file diff --git a/UrlboxSDK.MsTest/Webhook/Resource/UrlboxWebhookResponseTest.cs b/UrlboxSDK.MsTest/Webhook/Resource/UrlboxWebhookResponseTest.cs new file mode 100644 index 0000000..edf748a --- /dev/null +++ b/UrlboxSDK.MsTest/Webhook/Resource/UrlboxWebhookResponseTest.cs @@ -0,0 +1,61 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Response.Resource; +using UrlboxSDK.Webhook.Resource; + +namespace UrlboxSDK.MsTest.Webhook.Resource; + +[TestClass] +public class UrlboxWebhookResponseTests +{ + [TestMethod] + public void WebhookMeta_creates() + { + Meta meta = new(startTime: "START", endTime: "END"); + Assert.IsInstanceOfType(meta, typeof(Meta)); + Assert.AreEqual("START", meta.StartTime); + Assert.AreEqual("END", meta.EndTime); + } + + [TestMethod] + public void UrlboxWebhookResponse_CreatesMinGetters() + { + SyncUrlboxResponse response = new( + renderUrl: "https://urlbox.com", + size: 12345 + ); + + Meta meta = new(startTime: "START", endTime: "END"); + + UrlboxWebhookResponse webhookResponse = new( + @event: "render.succeeded", + renderId: "renderId", + result: response, + meta: meta + ); + + Assert.IsInstanceOfType(webhookResponse, typeof(UrlboxWebhookResponse)); + Assert.IsInstanceOfType(webhookResponse.Result, typeof(SyncUrlboxResponse)); + Assert.AreEqual("render.succeeded", webhookResponse.Event); + Assert.AreSame(response, webhookResponse.Result); + Assert.AreSame(meta, webhookResponse.Meta); + } + + [TestMethod] + public void UrlboxWebhookResponse_CreatesMinGettersWithError() + { + ErrorUrlboxResponse.UrlboxError error = new(message: "message", code: null, errors: null); + Meta meta = new(startTime: "START", endTime: "END"); + + UrlboxWebhookResponse webhookResponse = new( + @event: "render.succeeded", + renderId: "renderId", + error: error, + meta: meta + ); + + Assert.IsInstanceOfType(webhookResponse, typeof(UrlboxWebhookResponse)); + Assert.AreEqual("render.succeeded", webhookResponse.Event); + Assert.AreSame(error, webhookResponse.Error); + Assert.AreSame(meta, webhookResponse.Meta); + } +} diff --git a/UrlboxSDK.MsTest/Webhook/Validator/UrlboxWebhookValidatorTest.cs b/UrlboxSDK.MsTest/Webhook/Validator/UrlboxWebhookValidatorTest.cs new file mode 100644 index 0000000..245b4e8 --- /dev/null +++ b/UrlboxSDK.MsTest/Webhook/Validator/UrlboxWebhookValidatorTest.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UrlboxSDK.Config.Resource; +using UrlboxSDK.Webhook.Resource; + +namespace UrlboxSDK.MsTest.Webhook.Validator; + +[TestClass] +public class UrlboxWebhookValidatorTests +{ + private Urlbox urlbox; + + [TestInitialize] + public void TestInitialize() + { + UrlboxConfig config = new() + { + Key = "key", + Secret = "secret", + WebhookSecret = "webhook_secret" + }; + urlbox = new Urlbox(config); + } + + [TestMethod] + public void VerifyWebhookSignature_Succeeds() + { + string urlboxSignature = "t=123456,sha256=41f85178517e8e031be5771ee4951bc3f6fbd871f41b4866546803576b1c3843"; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + UrlboxWebhookResponse result = urlbox.VerifyWebhookSignature(urlboxSignature, content); + + Assert.AreEqual(result.Event, "render.succeeded"); + Assert.AreEqual(result.RenderId, "e9617143-2a95-4962-9cc9-d72f3c413b9c"); + + Assert.AreEqual("https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png", result.Result.RenderUrl); + Assert.AreEqual(359081, result.Result.Size); + + Assert.AreEqual(result.Meta.StartTime, "2024-01-11T23:32:11.908Z"); + Assert.AreEqual(result.Meta.EndTime, "2024-01-11T23:33:32.500Z"); + } + + [TestMethod] + public void VerifyWebhookSignature_FailsNoTimestamp() + { + string urlboxSignature = ",sha256=41f85178517e8e031be5771ee4951bc3f6fbd871f41b4866546803576b1c3843"; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature(urlboxSignature, content)); + Assert.AreEqual(result.Message, "Unable to verify signature as header is empty or malformed. Please ensure you pass the `x-urlbox-signature` from the header of the webhook response."); + } + + [TestMethod] + public void VerifyWebhookSignature_FailsNoSha() + { + string urlboxSignature = "t=123456,"; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature(urlboxSignature, content)); + Assert.AreEqual(result.Message, "Unable to verify signature as header is empty or malformed. Please ensure you pass the `x-urlbox-signature` from the header of the webhook response."); + } + + [TestMethod] + public void Urlbox_createsWithWebhookValidator() + { + // Shar of 'content' should not match 321, but method should run if 'webhook' passed. + System.Exception result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature("t=123,sha256=321", "content")); + + Assert.AreEqual( + "Cannot verify that this response came from Urlbox. Double check that you're webhook secret is correct.", + result.Message + ); + } + + [TestMethod] + public void Urlbox_throwsWhenWithoutWebhookValidator() + { + UrlboxConfig config = new() + { + Key = "key", + Secret = "secret" + }; + urlbox = new Urlbox(config); + // Should throw bc no webhook set so no validator instance + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature("t=123,sha256=321", "content")); + Assert.AreEqual(result.Message, "Please set your webhook secret in the Urlbox instance before calling this method."); + } + + [TestMethod] + public void VerifyWebhookSignature_FailsShaEmpty() + { + string urlboxSignature = "t=123456,sha256="; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature(urlboxSignature, content)); + Assert.AreEqual("The signature could not be found, please ensure you are passing the x-urlbox-signature header.", result.Message); + } + + [TestMethod] + public void VerifyWebhookSignature_FailsTimestampEmpty() + { + string urlboxSignature = "t=,sha256=41f85178517e8e031be5771ee4951bc3f6fbd871f41b4866546803576b1c3843"; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature(urlboxSignature, content)); + Assert.AreEqual("The timestamp could not be found, please ensure you are passing the x-urlbox-signature header.", result.Message); + } + + [TestMethod] + public void VerifyWebhookSignature_FailsNoComma() + { + string urlboxSignature = "t=12345sha256=41f85178517e8e031be5771ee4951bc3f6fbd871f41b4866546803576b1c3843"; + string content = "{\"event\":\"render.succeeded\",\"renderId\":\"e9617143-2a95-4962-9cc9-d72f3c413b9c\",\"result\":{\"renderUrl\":\"https://renders.urlbox.com/ub-temp-renders/renders/571f54138cd8b877077d3788/2024/1/11/e9617143-2a95-4962-9cc9-d72f3c413b9c.png\",\"size\":359081},\"meta\":{\"startTime\": \"2024-01-11T23:32:11.908Z\",\"endTime\":\"2024-01-11T23:33:32.500Z\"}}"; + ArgumentException result = Assert.ThrowsException(() => urlbox.VerifyWebhookSignature(urlboxSignature, content)); + Assert.AreEqual("Unable to verify signature as header is empty or malformed. Please ensure you pass the `x-urlbox-signature` from the header of the webhook response.", result.Message); + } +} \ No newline at end of file diff --git a/Urlbox/.gitignore b/UrlboxSDK/.gitignore similarity index 100% rename from Urlbox/.gitignore rename to UrlboxSDK/.gitignore diff --git a/UrlboxSDK/Config/Resource/UrlboxConfig.cs b/UrlboxSDK/Config/Resource/UrlboxConfig.cs new file mode 100644 index 0000000..5e77077 --- /dev/null +++ b/UrlboxSDK/Config/Resource/UrlboxConfig.cs @@ -0,0 +1,30 @@ +namespace UrlboxSDK.Config.Resource; + +/// +/// Represents the config settings for Urlbox, specifically for DI. +/// Encapsulates config details, making them easy to manage and inject into services +/// instead of passing parameters directly to the constructor. +/// +public class UrlboxConfig +{ + public string? Key { get; set; } + public string? Secret { get; set; } + public string? WebhookSecret { get; set; } + public string BaseUrl { get; set; } = Urlbox.BASE_URL; + + /// + /// Allows for parameterless construction of UrlboxConfig while still validating presence of key/secret + /// + /// + public void Validate() + { + if (string.IsNullOrEmpty(Key)) + { + throw new ArgumentException("Please provide your Urlbox.com API Key"); + } + if (string.IsNullOrEmpty(Secret)) + { + throw new ArgumentException("Please provide your Urlbox.com API Secret"); + } + } +} \ No newline at end of file diff --git a/UrlboxSDK/DI/UrlboxExtension.cs b/UrlboxSDK/DI/UrlboxExtension.cs new file mode 100644 index 0000000..3848385 --- /dev/null +++ b/UrlboxSDK/DI/UrlboxExtension.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK.DI.Extension; +/// +/// Provides extension methods for registering Urlbox services in the dependency injection container. +/// +public static class UrlboxExtension +{ + /// + /// Registers the Urlbox class and its configuration in the dependency injection container. + /// + /// The to add the service to. + /// An action to configure the options. + /// The updated . + public static IServiceCollection AddUrlbox( + this IServiceCollection services, + Action configure, + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + { + // Add config to ServiceProvider + services.Configure(configure); + + // Register Urlbox service with lifetime + services.Add(new ServiceDescriptor(typeof(IUrlbox), serviceProvider => + { + UrlboxConfig config = serviceProvider.GetRequiredService>().Value; + config.Validate(); + return new Urlbox(config); + }, lifetime)); + return services; + } +} diff --git a/UrlboxSDK/Exception/UrlboxException.cs b/UrlboxSDK/Exception/UrlboxException.cs new file mode 100644 index 0000000..15d1661 --- /dev/null +++ b/UrlboxSDK/Exception/UrlboxException.cs @@ -0,0 +1,32 @@ +using UrlboxSDK.Response.Resource; + +namespace UrlboxSDK.Exception; + +public sealed class UrlboxException : System.Exception +{ + public string RequestId { get; } + public string? Code { get; } + public string? Errors { get; } + + public UrlboxException(ErrorUrlboxResponse.UrlboxError error, string requestId) + : base(error.Message) + { + RequestId = requestId ?? "Unknown Request ID"; + if (!string.IsNullOrEmpty(error.Code)) Code = error.Code; + if (!string.IsNullOrEmpty(error.Errors)) Errors = error.Errors; + } + + public static UrlboxException FromResponse(string response, JsonSerializerOptions deserializerOptions) + { + if (string.IsNullOrWhiteSpace(response)) + throw new ArgumentException("Response cannot be null or empty", nameof(response)); + + ErrorUrlboxResponse? root = JsonSerializer.Deserialize(response, deserializerOptions); + if (root == null || root?.Error == null || string.IsNullOrWhiteSpace(root?.Error.Message) || string.IsNullOrWhiteSpace(root.RequestId)) + { + throw new JsonException("Invalid JSON response structure"); + } + + throw new UrlboxException(root.Error, root.RequestId); + } +} diff --git a/UrlboxSDK/Factory/IUrlboxFactory.cs b/UrlboxSDK/Factory/IUrlboxFactory.cs new file mode 100644 index 0000000..beefa8b --- /dev/null +++ b/UrlboxSDK/Factory/IUrlboxFactory.cs @@ -0,0 +1,8 @@ +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK.Factory; + +public interface IUrlboxFactory +{ + IUrlbox Create(UrlboxConfig config); +} diff --git a/UrlboxSDK/Factory/RenderLinkFactory.cs b/UrlboxSDK/Factory/RenderLinkFactory.cs new file mode 100644 index 0000000..b869bb8 --- /dev/null +++ b/UrlboxSDK/Factory/RenderLinkFactory.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Security.Cryptography; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Options.Validation; + +namespace UrlboxSDK.Factory; + +/// +/// A class encompassing render link generation logic. +/// +sealed class RenderLinkFactory +{ + private readonly string key; + private readonly string secret; + + public RenderLinkFactory(string key, string secret) + { + this.key = key; + this.secret = secret; + } + + /// + /// Turns an instance of UrlboxOptions into a URL query string. + /// + /// + /// A string with a formed query based on the options. + private static string ToQueryString(UrlboxOptions options) + { + // Filter by reflection class' props + PropertyInfo[] properties = options.GetType().GetProperties(); + string[] result = properties + .Where(prop => + { + // Filter out falsy values + object? value = prop.GetValue(options, null); + return UrlboxOptionsValidation.IsNullOption(value); + }) + .OrderBy(prop => prop.Name) + // Convert not null values to string representation + .Select(prop => + { + object? propValue = prop.GetValue(options) ?? + throw new ArgumentException($"Cannot convert options to a query string: trying to convert {prop.Name} which has a null value."); + string stringValue = ConvertToString(propValue); + return new KeyValuePair(prop.Name, stringValue); + }) + .Where(pair => !pair.Key.ToLower().Equals("format")) // Skip 'format' if present + .Select(pair => string.Format("{0}={1}", FormatKeyName(pair.Key), Uri.EscapeDataString(pair.Value))) + .ToArray(); + + return string.Join("&", result); + } + + /// + /// Formats an input to snake_case + /// + /// + /// The snake_case variant of the string input + private static string FormatKeyName(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; // Return as-is if input is null or empty + } + + return input switch + { + "FailOn5Xx" => "fail_on_5xx", + "FailOn4Xx" => "fail_on_4xx", + "Highlightfg" => "highlight_fg", + "Highlightbg" => "highlight_bg", + "S3Storageclass" => "s3_storage_class", + _ => ConvertToSnakeCase(input) + }; + } + + /// + /// Converts a string to snake_case. + /// + /// The input string to convert. + /// The snake_case representation of the string. + private static string ConvertToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + StringBuilder result = new(); + + for (int i = 0; i < input.Length; i++) + { + char currentChar = input[i]; + char? previousChar = i > 0 ? input[i - 1] : (char?)null; + + // Add an underscore before an uppercase letter when: + // - It's not the first character + // - The previous character is not an underscore + // - The previous character is not uppercase + if (i > 0 && + char.IsUpper(currentChar) && + previousChar.HasValue && + previousChar != '_' && + !char.IsUpper(previousChar.Value)) + { + result.Append('_'); + } + + result.Append(currentChar); + } + + return result.ToString().ToLower(); + } + + /// + /// Converts object types to string representations, including the custom Urlbox types. + /// Throws exception if the value is null or cannot be converted. + /// + /// The object to convert to a string. + /// A string rep of the provided object. + private static string ConvertToString(object value) + { + if (!UrlboxOptionsValidation.IsNullOption(value)) + { + throw new System.Exception("Value contains no valid content."); + } + + return value switch + { + string[] stringArray => string.Join(",", stringArray), + Enum enumValue => enumValue.ToString().ToLower(), + bool boolValue => boolValue.ToString().ToLower(), + // Default case: Convert all other types using Convert.ToString + _ => Convert.ToString(value) + ?? throw new System.Exception("Could not convert value to string.") + }; + } + + /// + /// Generates a Urlbox render link. + /// + /// + /// + /// The Urlbox Render Link + public string GenerateRenderLink(string baseUrl, UrlboxOptions options, bool sign = true) + { + // Either the options.Format or PNG as default + string format = options.Format?.ToString().ToLower() ?? "png"; + + string queryString = ToQueryString(options); + if (sign) + { + return string.Format( + baseUrl + "/v1/{0}/{1}/{2}?{3}", + key, + GenerateToken(queryString), + format, + queryString + ); + } + else + { + return string.Format( + baseUrl + "/v1/{0}/{1}?{2}", + key, + format, + queryString + ); + } + } + + /// + /// Generates a signed variant of one's secret Urlbox token. + /// + /// + /// The signed token + private string GenerateToken(string queryString) + { + HMACSHA1 sha = new(Encoding.UTF8.GetBytes(secret)); + MemoryStream stream = new(Encoding.UTF8.GetBytes(queryString)); + return sha.ComputeHash(stream).Aggregate("", (current, next) => current + string.Format("{0:x2}", next), current => current); + } +} diff --git a/UrlboxSDK/Factory/UrlboxFactory.cs b/UrlboxSDK/Factory/UrlboxFactory.cs new file mode 100644 index 0000000..486c8c0 --- /dev/null +++ b/UrlboxSDK/Factory/UrlboxFactory.cs @@ -0,0 +1,35 @@ +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK.Factory; + +public class UrlboxFactory : IUrlboxFactory +{ + public IUrlbox Create(UrlboxConfig config) + { + config.Validate(); + return new Urlbox(config); + } + + // STATIC + + /// + /// A static method to create a new instance of the Urlbox class + /// + /// + /// + /// + /// + /// A new instance of the Urlbox class. + /// Thrown when there is no api key or secret + public static Urlbox FromCredentials(string apiKey, string apiSecret, string? webhookSecret) + { + UrlboxConfig config = new() + { + Key = apiKey, + Secret = apiSecret, + WebhookSecret = webhookSecret, + }; + config.Validate(); + return new Urlbox(config); + } +} diff --git a/UrlboxSDK/GlobalUsings.cs b/UrlboxSDK/GlobalUsings.cs new file mode 100644 index 0000000..b513dc7 --- /dev/null +++ b/UrlboxSDK/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System; +global using System.Text; +global using System.Text.Json; +global using System.IO; +global using System.Linq; diff --git a/UrlboxSDK/IUrlbox.cs b/UrlboxSDK/IUrlbox.cs new file mode 100644 index 0000000..e42f7fe --- /dev/null +++ b/UrlboxSDK/IUrlbox.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using UrlboxSDK.Metadata.Resource; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Response.Resource; +using UrlboxSDK.Webhook.Resource; + +namespace UrlboxSDK; + +public interface IUrlbox +{ + // Screenshot and File Generation Methods + Task TakeScreenshot(UrlboxOptions options); + Task TakeScreenshot(UrlboxOptions options, int timeout); + Task TakePdf(UrlboxOptions options); + Task TakeMp4(UrlboxOptions options); + Task Render(UrlboxOptions options); + Task Render(IDictionary options); + Task RenderAsync(UrlboxOptions options); + Task RenderAsync(IDictionary options); + Task TakeScreenshotWithMetadata(UrlboxOptions options); + + // Extraction Methods + + Task ExtractMetadata(UrlboxOptions options); + Task ExtractMarkdown(UrlboxOptions options); + Task ExtractHtml(UrlboxOptions options); + Task ExtractMhtml(UrlboxOptions options); + + // Download and File Handling Methods + Task DownloadAsBase64(UrlboxOptions options, bool sign = true); + Task DownloadAsBase64(string urlboxUrl); + Task DownloadToFile(string urlboxUrl, string filename); + Task DownloadToFile(UrlboxOptions options, string filename, bool sign = true); + + // URL Generation Methods + string GeneratePNGUrl(UrlboxOptions options, bool sign = true); + string GenerateJPEGUrl(UrlboxOptions options, bool sign = true); + string GeneratePDFUrl(UrlboxOptions options, bool sign = true); + string GenerateRenderLink(UrlboxOptions options, bool sign = true); + + // Status and Validation Methods + Task GetStatus(string statusUrl); + UrlboxWebhookResponse VerifyWebhookSignature(string header, string content); +} diff --git a/UrlboxSDK/LICENSE.txt b/UrlboxSDK/LICENSE.txt new file mode 100644 index 0000000..9330a64 --- /dev/null +++ b/UrlboxSDK/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Urlbox Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/UrlboxSDK/Metadata/Resource/OgImage.cs b/UrlboxSDK/Metadata/Resource/OgImage.cs new file mode 100644 index 0000000..45eb71d --- /dev/null +++ b/UrlboxSDK/Metadata/Resource/OgImage.cs @@ -0,0 +1,20 @@ +namespace UrlboxSDK.Metadata.Resource; + +/// +/// Represents an Open Graph Image +/// +public sealed class OgImage +{ + public string Url { get; } + public string? Type { get; } + public string Width { get; } + public string Height { get; } + + public OgImage(string url, string width, string height, string? type = null) + { + Url = url; + Width = width; + Height = height; + if (type != null) Type = type; + } +} diff --git a/UrlboxSDK/Metadata/Resource/UrlboxMetadata.cs b/UrlboxSDK/Metadata/Resource/UrlboxMetadata.cs new file mode 100644 index 0000000..fd7c330 --- /dev/null +++ b/UrlboxSDK/Metadata/Resource/UrlboxMetadata.cs @@ -0,0 +1,116 @@ +using System.Text.Json.Serialization; + +namespace UrlboxSDK.Metadata.Resource; + +/// +/// Represents Metadata for a Urlbox Response when save_metadata or metadata options are set to true +/// +public sealed class UrlboxMetadata +{ + public string UrlRequested { get; } + public string UrlResolved { get; } + public string Url { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Author { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Date { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Image { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Logo { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Publisher { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgTitle { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public OgImage[]? OgImage { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgDescription { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgUrl { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgType { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgSiteName { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OgLocale { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Charset { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TwitterCard { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TwitterSite { get; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TwitterCreator { get; } + + public UrlboxMetadata( + string urlRequested, + string urlResolved, + string url, + string? author = null, + string? date = null, + string? description = null, + string? image = null, + string? logo = null, + string? publisher = null, + string? title = null, + string? ogTitle = null, + OgImage[]? ogImage = null, + string? ogDescription = null, + string? ogUrl = null, + string? ogType = null, + string? ogSiteName = null, + string? ogLocale = null, + string? charset = null, + string? twitterCard = null, + string? twitterSite = null, + string? twitterCreator = null + ) + { + UrlRequested = urlRequested ?? throw new ArgumentNullException(nameof(urlRequested)); + UrlResolved = urlResolved ?? throw new ArgumentNullException(nameof(urlResolved)); + Url = url ?? throw new ArgumentNullException(nameof(url)); + + if (author != null) Author = author; + if (date != null) Date = date; + if (description != null) Description = description; + if (image != null) Image = image; + if (logo != null) Logo = logo; + if (publisher != null) Publisher = publisher; + if (title != null) Title = title; + if (ogTitle != null) OgTitle = ogTitle; + if (ogImage != null) OgImage = ogImage; + if (ogDescription != null) OgDescription = ogDescription; + if (ogUrl != null) OgUrl = ogUrl; + if (ogType != null) OgType = ogType; + if (ogSiteName != null) OgSiteName = ogSiteName; + if (twitterCard != null) TwitterCard = twitterCard; + if (twitterSite != null) TwitterSite = twitterSite; + if (twitterCreator != null) TwitterCreator = twitterCreator; + if (ogLocale != null) OgLocale = ogLocale; + if (charset != null) Charset = charset; + } +} + diff --git a/UrlboxSDK/Options/Builder/UrlboxOptionsBuilder.cs b/UrlboxSDK/Options/Builder/UrlboxOptionsBuilder.cs new file mode 100644 index 0000000..a9fd4a3 --- /dev/null +++ b/UrlboxSDK/Options/Builder/UrlboxOptionsBuilder.cs @@ -0,0 +1,703 @@ +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Options.Validation; + +namespace UrlboxSDK.Options.Builder; + +public sealed class UrlboxOptionsBuilder +{ + private readonly UrlboxOptions _options; + + /// + /// Constructor + /// + /// + /// + public UrlboxOptionsBuilder(string? url = null, string? html = null) + { + _options = new UrlboxOptions( + url, + html + ); + } + + /// + /// Builds the UrlboxOptions instance after validating. + /// + /// + public UrlboxOptions Build() + { + return UrlboxOptionsValidation.Validate(_options); + } + + public UrlboxOptionsBuilder WebhookUrl(string webhookUrl) + { + _options.WebhookUrl = webhookUrl; + return this; + } + + public UrlboxOptionsBuilder Format(Format format) + { + _options.Format = format; + return this; + } + + public UrlboxOptionsBuilder Width(int width) + { + _options.Width = width; + return this; + } + + public UrlboxOptionsBuilder Height(int height) + { + _options.Height = height; + return this; + } + + public UrlboxOptionsBuilder FullPage() + { + _options.FullPage = true; + return this; + } + + public UrlboxOptionsBuilder Selector(string selector) + { + _options.Selector = selector; + return this; + } + + public UrlboxOptionsBuilder Clip(string clip) + { + _options.Clip = clip; + return this; + } + + public UrlboxOptionsBuilder Gpu() + { + _options.Gpu = true; + return this; + } + + public UrlboxOptionsBuilder BlockAds() + { + _options.BlockAds = true; + return this; + } + + public UrlboxOptionsBuilder HideCookieBanners() + { + _options.HideCookieBanners = true; + return this; + } + + public UrlboxOptionsBuilder ClickAccept() + { + _options.ClickAccept = true; + return this; + } + + public UrlboxOptionsBuilder BlockUrls(params string[] blockUrls) + { + _options.BlockUrls = blockUrls; + return this; + } + + public UrlboxOptionsBuilder BlockImages() + { + _options.BlockImages = true; + return this; + } + + public UrlboxOptionsBuilder BlockFonts() + { + _options.BlockFonts = true; + return this; + } + + public UrlboxOptionsBuilder BlockMedias() + { + _options.BlockMedias = true; + return this; + } + + public UrlboxOptionsBuilder BlockStyles() + { + _options.BlockStyles = true; + return this; + } + + public UrlboxOptionsBuilder BlockScripts() + { + _options.BlockScripts = true; + return this; + } + + public UrlboxOptionsBuilder BlockFrames() + { + _options.BlockFrames = true; + return this; + } + + public UrlboxOptionsBuilder BlockFetch() + { + _options.BlockFetch = true; + return this; + } + + public UrlboxOptionsBuilder BlockXhr() + { + _options.BlockXhr = true; + return this; + } + + public UrlboxOptionsBuilder BlockSockets() + { + _options.BlockSockets = true; + return this; + } + + public UrlboxOptionsBuilder HideSelector(string hideSelector) + { + _options.HideSelector = hideSelector; + return this; + } + + public UrlboxOptionsBuilder Js(string js) + { + _options.Js = js; + return this; + } + + public UrlboxOptionsBuilder Css(string css) + { + _options.Css = css; + return this; + } + + public UrlboxOptionsBuilder DarkMode() + { + _options.DarkMode = true; + return this; + } + + public UrlboxOptionsBuilder ReducedMotion() + { + _options.ReducedMotion = true; + return this; + } + + public UrlboxOptionsBuilder Retina() + { + _options.Retina = true; + return this; + } + + public UrlboxOptionsBuilder ThumbWidth(int thumbWidth) + { + _options.ThumbWidth = thumbWidth; + return this; + } + + public UrlboxOptionsBuilder ThumbHeight(int thumbHeight) + { + _options.ThumbHeight = thumbHeight; + return this; + } + + public UrlboxOptionsBuilder ImgFit(ImgFit imgFit) + { + _options.ImgFit = imgFit; + return this; + } + + public UrlboxOptionsBuilder ImgPosition(ImgPosition imgPosition) + { + _options.ImgPosition = imgPosition; + return this; + } + + public UrlboxOptionsBuilder ImgBg(string imgBg) + { + _options.ImgBg = imgBg; + return this; + } + + public UrlboxOptionsBuilder ImgPad(string imgPad) + { + _options.ImgPad = imgPad; + return this; + } + + public UrlboxOptionsBuilder Quality(int quality) + { + _options.Quality = quality; + return this; + } + + public UrlboxOptionsBuilder Transparent() + { + _options.Transparent = true; + return this; + } + + public UrlboxOptionsBuilder MaxHeight(int maxHeight) + { + _options.MaxHeight = maxHeight; + return this; + } + + public UrlboxOptionsBuilder Download(string download) + { + _options.Download = download; + return this; + } + + public UrlboxOptionsBuilder PdfPageSize(PdfPageSize pdfPageSize) + { + _options.PdfPageSize = pdfPageSize; + return this; + } + + public UrlboxOptionsBuilder PdfPageRange(string pdfPageRange) + { + _options.PdfPageRange = pdfPageRange; + return this; + } + + public UrlboxOptionsBuilder PdfPageWidth(int pdfPageWidth) + { + _options.PdfPageWidth = pdfPageWidth; + return this; + } + + public UrlboxOptionsBuilder PdfPageHeight(int pdfPageHeight) + { + _options.PdfPageHeight = pdfPageHeight; + return this; + } + + public UrlboxOptionsBuilder PdfMargin(PdfMargin pdfMargin) + { + _options.PdfMargin = pdfMargin; + return this; + } + + public UrlboxOptionsBuilder PdfMarginTop(int pdfMarginTop) + { + _options.PdfMarginTop = pdfMarginTop; + return this; + } + + public UrlboxOptionsBuilder PdfMarginRight(int pdfMarginRight) + { + _options.PdfMarginRight = pdfMarginRight; + return this; + } + + public UrlboxOptionsBuilder PdfMarginBottom(int pdfMarginBottom) + { + _options.PdfMarginBottom = pdfMarginBottom; + return this; + } + + public UrlboxOptionsBuilder PdfMarginLeft(int pdfMarginLeft) + { + _options.PdfMarginLeft = pdfMarginLeft; + return this; + } + + public UrlboxOptionsBuilder PdfAutoCrop() + { + _options.PdfAutoCrop = true; + return this; + } + + public UrlboxOptionsBuilder PdfScale(double pdfScale) + { + _options.PdfScale = pdfScale; + return this; + } + + public UrlboxOptionsBuilder PdfOrientation(PdfOrientation pdfOrientation) + { + _options.PdfOrientation = pdfOrientation; + return this; + } + + public UrlboxOptionsBuilder PdfBackground() + { + _options.PdfBackground = true; + return this; + } + + public UrlboxOptionsBuilder DisableLigatures() + { + _options.DisableLigatures = true; + return this; + } + + public UrlboxOptionsBuilder Media(Media media) + { + _options.Media = media; + return this; + } + + public UrlboxOptionsBuilder PdfShowHeader() + { + _options.PdfShowHeader = true; + return this; + } + + public UrlboxOptionsBuilder PdfHeader(string pdfHeader) + { + _options.PdfHeader = pdfHeader; + return this; + } + + public UrlboxOptionsBuilder PdfShowFooter() + { + _options.PdfShowFooter = true; + return this; + } + + public UrlboxOptionsBuilder PdfFooter(string pdfFooter) + { + _options.PdfFooter = pdfFooter; + return this; + } + + public UrlboxOptionsBuilder Readable() + { + _options.Readable = true; + return this; + } + + public UrlboxOptionsBuilder Force() + { + _options.Force = true; + return this; + } + + public UrlboxOptionsBuilder Unique(string unique) + { + _options.Unique = unique; + return this; + } + + public UrlboxOptionsBuilder Ttl(int ttl) + { + _options.Ttl = ttl; + return this; + } + + public UrlboxOptionsBuilder Proxy(string proxy) + { + _options.Proxy = proxy; + return this; + } + + public UrlboxOptionsBuilder Header(params string[] header) + { + _options.Header = header; + return this; + } + + public UrlboxOptionsBuilder Cookie(params string[] cookie) + { + _options.Cookie = cookie; + return this; + } + + public UrlboxOptionsBuilder UserAgent(string userAgent) + { + _options.UserAgent = userAgent; + return this; + } + + public UrlboxOptionsBuilder Platform(string platform) + { + _options.Platform = platform; + return this; + } + + public UrlboxOptionsBuilder AcceptLang(string acceptLang) + { + _options.AcceptLang = acceptLang; + return this; + } + + public UrlboxOptionsBuilder Authorization(string authorization) + { + _options.Authorization = authorization; + return this; + } + + public UrlboxOptionsBuilder Tz(string tz) + { + _options.Tz = tz; + return this; + } + + public UrlboxOptionsBuilder EngineVersion(EngineVersion engineVersion) + { + _options.EngineVersion = engineVersion; + return this; + } + + public UrlboxOptionsBuilder Delay(int delay) + { + _options.Delay = delay; + return this; + } + + public UrlboxOptionsBuilder Timeout(int timeout) + { + _options.Timeout = timeout; + return this; + } + + public UrlboxOptionsBuilder WaitUntil(WaitUntil waitUntil) + { + _options.WaitUntil = waitUntil; + return this; + } + + public UrlboxOptionsBuilder WaitFor(string waitFor) + { + _options.WaitFor = waitFor; + return this; + } + + public UrlboxOptionsBuilder WaitToLeave(string waitToLeave) + { + _options.WaitToLeave = waitToLeave; + return this; + } + + public UrlboxOptionsBuilder WaitTimeout(int waitTimeout) + { + _options.WaitTimeout = waitTimeout; + return this; + } + + public UrlboxOptionsBuilder FailIfSelectorMissing() + { + _options.FailIfSelectorMissing = true; + return this; + } + + public UrlboxOptionsBuilder FailIfSelectorPresent() + { + _options.FailIfSelectorPresent = true; + return this; + } + + public UrlboxOptionsBuilder FailOn4xx() + { + _options.FailOn4Xx = true; + return this; + } + + public UrlboxOptionsBuilder FailOn5xx() + { + _options.FailOn5Xx = true; + return this; + } + + public UrlboxOptionsBuilder ScrollTo(string scrollTo) + { + _options.ScrollTo = scrollTo; + return this; + } + + public UrlboxOptionsBuilder Click(params string[] click) + { + _options.Click = click; + return this; + } + + public UrlboxOptionsBuilder ClickAll(params string[] clickAll) + { + _options.ClickAll = clickAll; + return this; + } + + public UrlboxOptionsBuilder Hover(params string[] hover) + { + _options.Hover = hover; + return this; + } + + public UrlboxOptionsBuilder BgColor(string bgColor) + { + _options.BgColor = bgColor; + return this; + } + + public UrlboxOptionsBuilder DisableJs() + { + _options.DisableJs = true; + return this; + } + + public UrlboxOptionsBuilder FullPageMode(FullPageMode fullPageMode) + { + _options.FullPageMode = fullPageMode; + return this; + } + + public UrlboxOptionsBuilder FullWidth() + { + _options.FullWidth = true; + return this; + } + + public UrlboxOptionsBuilder AllowInfinite() + { + _options.AllowInfinite = true; + return this; + } + + public UrlboxOptionsBuilder SkipScroll() + { + _options.SkipScroll = true; + return this; + } + + public UrlboxOptionsBuilder DetectFullHeight() + { + _options.DetectFullHeight = true; + return this; + } + + public UrlboxOptionsBuilder MaxSectionHeight(int maxSectionHeight) + { + _options.MaxSectionHeight = maxSectionHeight; + return this; + } + + public UrlboxOptionsBuilder ScrollIncrement(int scrollIncrement) + { + _options.ScrollIncrement = scrollIncrement; + return this; + } + + public UrlboxOptionsBuilder ScrollDelay(int scrollDelay) + { + _options.ScrollDelay = scrollDelay; + return this; + } + + public UrlboxOptionsBuilder Highlight(string highlight) + { + _options.Highlight = highlight; + return this; + } + + public UrlboxOptionsBuilder Highlightfg(string Highlightfg) + { + _options.Highlightfg = Highlightfg; + return this; + } + + public UrlboxOptionsBuilder Highlightbg(string Highlightbg) + { + _options.Highlightbg = Highlightbg; + return this; + } + + public UrlboxOptionsBuilder Latitude(double latitude) + { + _options.Latitude = latitude; + return this; + } + + public UrlboxOptionsBuilder Longitude(double longitude) + { + _options.Longitude = longitude; + return this; + } + + public UrlboxOptionsBuilder Accuracy(int accuracy) + { + _options.Accuracy = accuracy; + return this; + } + + public UrlboxOptionsBuilder UseS3() + { + _options.UseS3 = true; + return this; + } + + public UrlboxOptionsBuilder S3Path(string s3Path) + { + _options.S3Path = s3Path; + return this; + } + + public UrlboxOptionsBuilder S3Bucket(string s3Bucket) + { + _options.S3Bucket = s3Bucket; + return this; + } + + public UrlboxOptionsBuilder S3Endpoint(string s3Endpoint) + { + _options.S3Endpoint = s3Endpoint; + return this; + } + + public UrlboxOptionsBuilder S3Region(string s3Region) + { + _options.S3Region = s3Region; + return this; + } + + public UrlboxOptionsBuilder CdnHost(string cdnHost) + { + _options.CdnHost = cdnHost; + return this; + } + + public UrlboxOptionsBuilder S3Storageclass(S3Storageclass s3Storageclass) + { + _options.S3Storageclass = s3Storageclass; + return this; + } + + public UrlboxOptionsBuilder SaveHtml() + { + _options.SaveHtml = true; + return this; + } + + public UrlboxOptionsBuilder SaveMhtml() + { + _options.SaveMhtml = true; + return this; + } + + public UrlboxOptionsBuilder SaveMarkdown() + { + _options.SaveMarkdown = true; + return this; + } + + public UrlboxOptionsBuilder SaveMetadata() + { + _options.SaveMetadata = true; + return this; + } + + public UrlboxOptionsBuilder Metadata() + { + _options.Metadata = true; + return this; + } + + public UrlboxOptionsBuilder VideoScroll() + { + _options.VideoScroll = true; + return this; + } +} diff --git a/UrlboxSDK/Options/Resource/UrlboxOptions.cs b/UrlboxSDK/Options/Resource/UrlboxOptions.cs new file mode 100644 index 0000000..1c35461 --- /dev/null +++ b/UrlboxSDK/Options/Resource/UrlboxOptions.cs @@ -0,0 +1,2863 @@ +// +// +// To parse this JSON data, add NuGet 'System.Text.Json' then do: +// +// using UrlboxSDK.Options.Resource; +// +// var urlboxOptions = UrlboxOptions.FromJson(jsonString); +#nullable enable +#pragma warning disable CS8618 +#pragma warning disable CS8601 +#pragma warning disable CS8603 + +namespace UrlboxSDK.Options.Resource +{ + using System; + using System.Collections.Generic; + + using System.Text.Json; + using System.Text.Json.Serialization; + using System.Globalization; + + public partial class UrlboxOptions + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("accept_cookies")] + public bool? AcceptCookies { get; set; } + + /// + /// Sets an `Accept-Language` header on requests to the target URL + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("accept_lang")] + public string AcceptLang { get; set; } + + /// + /// Sets the accurate of the Geolocation API in metres. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("accuracy")] + public double? Accuracy { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("allow_coin")] + public bool? AllowCoin { get; set; } + + /// + /// By default, when Urlbox detects an infinite scrolling page, it does not attempt to + /// continue scrolling to the bottom, as this could result in infinite scrolling! If you want + /// to override this behaviour, pass `true` for this option. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("allow_infinite")] + public bool? AllowInfinite { get; set; } + + /// + /// Sets an `Authorization` header on requests to the target URL. Can be used to pass an auth + /// token through to the site in order to 'login' before rendering. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("authorization")] + public string Authorization { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("auto_crop")] + public bool? AutoCrop { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("auto_crop_bg")] + public string AutoCropBg { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("auto_crop_threshold")] + public long? AutoCropThreshold { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("base_url")] + public string BaseUrl { get; set; } + + /// + /// Specify a hex code or CSS color string to use as the background color Some websites don't + /// set a body background colour, and will show up as transparent backgrounds with PNG, or + /// black when using JPG. Use this setting to set a background colour. If the website + /// explicitly sets a transparent background on the html or body elements, this setting will + /// be overridden. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("bg_color")] + public string BgColor { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("blend_mode")] + public string BlendMode { get; set; } + + /// + /// Blocks requests from popular advertising networks from loading. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_ads")] + public bool? BlockAds { get; set; } + + /// + /// Block fetch requests from the target URL. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_fetch")] + public bool? BlockFetch { get; set; } + + /// + /// Blocks font requests + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_fonts")] + public bool? BlockFonts { get; set; } + + /// + /// Block frames. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_frames")] + public bool? BlockFrames { get; set; } + + /// + /// Blocks image requests + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_images")] + public bool? BlockImages { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_manifests")] + public bool? BlockManifests { get; set; } + + /// + /// Block video and audio requests + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_medias")] + public bool? BlockMedias { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_other")] + public bool? BlockOther { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_pings")] + public bool? BlockPings { get; set; } + + /// + /// Prevent requests for javascript scripts from loading + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_scripts")] + public bool? BlockScripts { get; set; } + + /// + /// Block websocket requests. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_sockets")] + public bool? BlockSockets { get; set; } + + /// + /// Prevent stylesheet requests from loading + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_styles")] + public bool? BlockStyles { get; set; } + + /// + /// Block requests from specific domains from loading. You can use wildcard characters such + /// as `*` to match subdomains. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_urls")] + public string[] BlockUrls { get; set; } + + /// + /// Block XHR requests from the target URL. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("block_xhr")] + public bool? BlockXhr { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("browser")] + public string Browser { get; set; } + + /// + /// If your custom bucket is fronted by a CDN, you can set the host name here. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("cdn_host")] + public string CdnHost { get; set; } + + /// + /// Specifies an element selector to click before generating a screenshot or PDF Example: + /// `#clickme` would click an element with `id="clickme"`. Can be used multiple times to + /// simulate multiple sequential click events. If the selector matches multiple elements, + /// only the first element will be clicked. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("click")] + public string[] Click { get; set; } + + /// + /// Similar to the [`hide_cookie_banners`](#hide_cookie_banners) option, but instead of + /// hiding the banners, this option attempts to click on the 'Accept' button, in order to + /// accept cookies. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("click_accept")] + public bool? ClickAccept { get; set; } + + /// + /// Specifies an element selector to click before generating a screenshot or PDF Example: + /// `.clickme` would click all elements with `class="clickme"`. Can be used multiple times + /// to simulate multiple sequential click events. If the selector matches multiple elements, + /// all elements will be clicked. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("click_all")] + public string[] ClickAll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("click_all_x")] + public string[] ClickAllX { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("click_x")] + public string[] ClickX { get; set; } + + /// + /// Clip the screenshot to the bounding box specified by `x,y,width,height`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("clip")] + public string Clip { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("color_profile")] + public ColorProfile? ColorProfile { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("continue_on_nav_error")] + public bool? ContinueOnNavError { get; set; } + + /// + /// Sets a cookie on the request when loading the URL. Example: To set the cookie with key + /// `Opt-In` to the value `yes`, you would set the value of this option to `Opt-In=yes`. + /// Cookies can be passed as an array, to allow setting multiple cookies - + /// e.g.`["Opt-In=yes","Session-Id=DMTIzNDU"]`. To achieve multiple cookies with render + /// links, just set the cookie option multiple times, like + /// `cookie=Opt-In%3Dyes&cookie=Session-Id%3DDMTIzNDU`. To set a specific domain on a cookie, + /// you can do the following: `OptIn=yes;Domain=.mydomain.com`. You can set other attributes + /// for the cookie such as `Path`, `HttpOnly` and `SameSite` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("cookie")] + public string[] Cookie { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("cookies")] + public string[] Cookies { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("crop_width")] + public long? CropWidth { get; set; } + + /// + /// Inject custom CSS into the page + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("css")] + public string Css { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("custom_data_variable")] + public string CustomDataVariable { get; set; } + + /// + /// Emulate dark mode on websites by setting `prefers-color-scheme: dark` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("dark_mode")] + public bool? DarkMode { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("debug")] + public bool? Debug { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("debug_url")] + public string DebugUrl { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("debug_video")] + public bool? DebugVideo { get; set; } + + /// + /// The amount of time to wait before Urlbox captures a render in milliseconds. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("delay")] + public long? Delay { get; set; } + + /// + /// Some pages have full-height backgrounds whose heights are set to 100% of the viewport. + /// This can cause the backgrounds to get stretched when making a full page screenshot. If + /// you are seeing this behaviour in your full page screenshots, pass `true` for this option. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("detect_full_height")] + public bool? DetectFullHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("disable_animations")] + public bool? DisableAnimations { get; set; } + + /// + /// Turns off javascript on the target URL. ~> Enabling this option will prevent + /// `full_page=true` and many other options, because having javascript disabled prevents + /// Urlbox from evaluating code inside the page's context. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("disable_js")] + public bool? DisableJs { get; set; } + + /// + /// Prevents ligatures from being used. Useful when rendering a PDF, and you want to extract + /// text which contains ligatures. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("disable_ligatures")] + public bool? DisableLigatures { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("disable_web_security")] + public bool? DisableWebSecurity { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("display_p3")] + public bool? DisplayP3 { get; set; } + + /// + /// Pass in a filename which sets the content-disposition header on the response. E.g. + /// `download=myfilename.png` This will make the Urlbox link downloadable, and will prompt + /// the user to save the file as `myfilename.png` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("download")] + public string Download { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("dpr")] + public double? Dpr { get; set; } + + /// + /// Sets the version of the urlbox rendering engine to use when rendering the page. This can + /// be useful for testing how a page will render in the latest version of our rendering + /// engine. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("engine_version")] + public EngineVersion? EngineVersion { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_if_captcha")] + public bool? FailIfCaptcha { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_if_cf_turnstile")] + public bool? FailIfCfTurnstile { get; set; } + + /// + /// Fails the request if the elements specified by `selector` or `wait_for` options are not + /// found on the page after waiting for `wait_timeout`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_if_selector_missing")] + public bool? FailIfSelectorMissing { get; set; } + + /// + /// Fails the request if the element specified by `wait_to_leave` option is found on the page + /// after waiting for `wait_timeout`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_if_selector_present")] + public bool? FailIfSelectorPresent { get; set; } + + /// + /// If `fail_on_4xx=true` and the requested URL returns a status code between 400 and 499, + /// Urlbox will fail the request with error code 400 and the message: `Failed to render. + /// Requested URL returned a 4xx error code and fail_on_4xx was true` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_on_4xx")] + public bool? FailOn4Xx { get; set; } + + /// + /// If `fail_on_5xx=true` and the requested URL returns a status code between 500 and 599, + /// Urlbox will fail the request with error code 400 and message: `Failed to render. + /// Requested URL returned a 5xx error code and fail_on_5xx was true` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_on_5xx")] + public bool? FailOn5Xx { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fail_on_metadata_error")] + public bool? FailOnMetadataError { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("finalRetry")] + public bool? FinalRetry { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fix_full_height")] + public bool? FixFullHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("flash")] + public bool? Flash { get; set; } + + /// + /// Generate a fresh screenshot or PDF, instead of getting a cached version. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("force")] + public bool? Force { get; set; } + + /// + /// The output format of the resulting render. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("format")] + public Format? Format { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("fragment")] + public bool? Fragment { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("freeze_fixed")] + public bool? FreezeFixed { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("from_html")] + public bool? FromHtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("full_html")] + public bool? FullHtml { get; set; } + + /// + /// Specify whether to capture the full scrollable area of the website. For PDFs, `full_page` + /// mode will attempt to capture the whole website onto one single page PDF document. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("full_page")] + public bool? FullPage { get; set; } + + /// + /// Whether to use scroll and stitch algorithm (the default) to render a full page + /// screenshot, or to use the native full page screenshot algorithm, which is faster, but can + /// be less accurate on some sites. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("full_page_mode")] + public FullPageMode? FullPageMode { get; set; } + + /// + /// When full_page=true, specify whether to capture the full width of the website, for + /// example if the site is horizontally scrolling. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("full_width")] + public bool? FullWidth { get; set; } + + /// + /// Enable GPU acceleration to render 3D scenes and heavy WebGL content. This is a beta + /// feature and requires pre-approval. Please contact support@urlbox.com to enable this + /// feature on your account. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("gpu")] + public bool? Gpu { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hdr10")] + public bool? Hdr10 { get; set; } + + /// + /// Set a header on the request when loading the URL Example: To set the header with key + /// `X-My-Header` to the value `SomeValue`, you would pass `header=X-My-Header%3DSomeValue`. + /// This can be set multiple times, to set more than one header - e.g. + /// `header=X-My-Header%3DSomeValue&header=X-My-Other-Header%3DSomeOtherValue`. As with all + /// options passed via the query string, the header value must be URL encoded - so + /// `X-My-Header=SomeValue` becomes `X-My-Header%3DSomeValue` in order to be interpreted + /// correctly by Urlbox. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("header")] + public string[] Header { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("headers")] + public string[] Headers { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("headless")] + public bool? Headless { get; set; } + + /// + /// The viewport height of the browser, in pixels. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("height")] + public long? Height { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("height_from")] + public string HeightFrom { get; set; } + + /// + /// Automatically hides cookie banners from most websites, by setting their style to + /// `display: none !important;` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hide_cookie_banners")] + public bool? HideCookieBanners { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hide_headless")] + public bool? HideHeadless { get; set; } + + /// + /// Comma-delimited string of CSS element selectors that are hidden by setting their style to + /// `display: none !important;`. Useful for hiding pop-ups. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hide_selector")] + public string HideSelector { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hide_selector_x")] + public string HideSelectorX { get; set; } + + /// + /// Specify a string to highlight on the page before capturing a screenshot or PDF. To + /// highlight multiple words, separate words with a pipe character e.g. Hello|World + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("highlight")] + public string Highlight { get; set; } + + /// + /// Specify the background color of the highlighted word. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("highlightbg")] + public string Highlightbg { get; set; } + + /// + /// Specify the text color of the highlighted word. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("highlightfg")] + public string Highlightfg { get; set; } + + /// + /// Specifies an element selector to hover over before generating a screenshot or PDF + /// Example: `#hoverme` would hover over the element with `id="hoverme"` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hover")] + public string[] Hover { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("hover_x")] + public string[] HoverX { get; set; } + + /// + /// The HTML you want to render. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("html")] + public string Html { get; set; } + + /// + /// Background colour to use when [img_fit](#img_fit) is `contain`, or [`img_pad`](#img_pad) + /// is used, defaults to black without transparency + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("img_bg")] + public string ImgBg { get; set; } + + /// + /// How the screenshot should be resized or cropped to fit the dimensions when using + /// [`thumb_width`](#thumb_width) and/or [`thumb_height`](#thumb_height) options + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("img_fit")] + public ImgFit? ImgFit { get; set; } + + /// + /// Pad the screenshot, giving it a border. Can either be a single pixel value that gets + /// added to each side, or a comma delimited string of `top,right,bottom,left` pixel values. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("img_pad")] + public string ImgPad { get; set; } + + /// + /// How the image should be positioned when using an [`img_fit`](#img_fit) of `cover` or + /// `contain`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("img_position")] + public ImgPosition? ImgPosition { get; set; } + + /// + /// Execute custom JavaScript in the context of the page. The JS gets executed after the + /// page's dom has loaded, but before the screenshot is taken. No need to use `load` etc + /// event handlers to run code, as these events will already have fired by the time this JS + /// gets executed. You can use `await` to wait for promises to resolve. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("js")] + public string Js { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("json")] + public bool? Json { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("jsx")] + public string Jsx { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("kill_popups")] + public bool? KillPopups { get; set; } + + /// + /// Sets the latitude used to emulate the Geolocation API. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("latitude")] + public double? Latitude { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("lazyload")] + public bool? Lazyload { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("lightweight")] + public bool? Lightweight { get; set; } + + /// + /// Sets the longitude used to emulate the Geolocation API. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("longitude")] + public double? Longitude { get; set; } + + /// + /// For extremely lengthy websites, it may be preferable to limit the screenshot to a maximum + /// height to prevent Urlbox from spending time scrolling and generating an enormous + /// screenshot. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_height")] + public long? MaxHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_scroll_page_time")] + public long? MaxScrollPageTime { get; set; } + + /// + /// When Urlbox takes a `full_page` screenshot, the maximum height of each image section is + /// set to 4096 pixels. If a sites height is greater than this value, Urlbox will start + /// splitting the screenshot into sections. Sometimes it is worthwhile experimenting with + /// this number. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_section_height")] + public long? MaxSectionHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_section_width")] + public long? MaxSectionWidth { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_sections")] + public long? MaxSections { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_width")] + public long? MaxWidth { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("max_xsections")] + public long? MaxXsections { get; set; } + + /// + /// By default, when generating a PDF, the `print` CSS media query is used. To generate a PDF + /// using the `screen` CSS, set this option to `screen`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("media")] + public Media? Media { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("metadata")] + public bool? Metadata { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("minidelay")] + public long? Minidelay { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("no_upload")] + public bool? NoUpload { get; set; } + + /// + /// Automatically remove white space from PDF. Occasionally a PDF will have a lot of trailing + /// white space at the bottom of the page. This option will attempt to automatically crop the + /// PDF to remove this white space. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_auto_crop")] + public bool? PdfAutoCrop { get; set; } + + /// + /// Sets whether to print background images in the PDF + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_background")] + public bool? PdfBackground { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_dpi")] + public double? PdfDpi { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_fit_to_page")] + public bool? PdfFitToPage { get; set; } + + /// + /// Change the default pdf footer that is shown on each page of the pdf when + /// [`pdf_show_footer`](#pdf_show_footer) option is set. You have the option to show the + /// following variables in the footer (or header) of the pdf: * current `date` * `title` of + /// the page * `url` of the page * current `pageNumber` * the `totalPages` in the pdf + /// document You can display these variables by creating empty divs or spans, with special + /// css class names relating to the variable you want to show. For example, if you want to + /// show the `date` followed by the `url`, you could use the following pdf footer template: + /// `
`. The pdf footer template you set are + /// inserted as the innerHTML of a parent div which is a flex container, and has + /// `align-items` set to `flex-end`. There are also some helper classes for aligning the divs + /// or spans. The following classes are available: * `left` - adds some left padding to the + /// element and sets `flex: none`. * `center` - aligns the element and text to the center. * + /// `right` - adds some right padding to the element and sets `flex: none`. * `text` - sets + /// the text to 8pt. * `grow` - sets `flex: auto` on the element, allowing it to grow to fill + /// the available space. The default pdf footer is: `
/
`. You can see exactly how the pdf page is constructed by + /// looking at the [chromium pdf + /// template](https://source.chromium.org/chromium/chromium/src/+/main:components/printing/resources/print_header_footer_template_page.html;l=101-105?q=header_footer%20&ss=chromium%2Fchromium%2Fsrc) + /// in the chromium source repository. + ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_footer")] + public string PdfFooter { get; set; } + + /// + /// Change the default pdf header that is shown on each page of the pdf when + /// [`pdf_show_header`](#pdf_show_header) option is set. You have the option to show the + /// following variables in the header (or footer) of the pdf: * current `date` * `title` of + /// the page * `url` of the page * current `pageNumber` * the `totalPages` in the pdf + /// document You can display these variables by creating empty divs or spans, with special + /// css class names relating to the variable you want to show. For example, if you want to + /// show the `date` followed by the `url`, you could use the following pdf header template: + /// `
`. The pdf header template you set are + /// inserted as the innerHTML of a parent div which is a flex container, and has + /// `align-items` set to `flex-start`. There are also some helper classes for aligning the + /// divs or spans. The following classes are available: * `left` - adds some left padding to + /// the element and sets `flex: none`. * `center` - aligns the element and text to the + /// center. * `right` - adds some right padding to the element and sets `flex: none`. * + /// `text` - sets the text to 8pt. * `grow` - sets `flex: auto` on the element, allowing it + /// to grow to fill the available space. The default pdf header is: `
`. You can see exactly how the pdf page + /// is constructed by looking at the [chromium pdf + /// template](https://source.chromium.org/chromium/chromium/src/+/main:components/printing/resources/print_header_footer_template_page.html;l=98-100?q=header_footer%20&ss=chromium%2Fchromium%2Fsrc) + /// in the chromium source repository. + ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_header")] + public string PdfHeader { get; set; } + + /// + /// Sets the margin of the PDF document. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_margin")] + public PdfMargin? PdfMargin { get; set; } + + /// + /// Sets a custom bottom margin on the PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_margin_bottom")] + public double? PdfMarginBottom { get; set; } + + /// + /// Set a custom left margin on the PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_margin_left")] + public double? PdfMarginLeft { get; set; } + + /// + /// Sets a custom right margin on the PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_margin_right")] + public double? PdfMarginRight { get; set; } + + /// + /// Sets a custom top margin on the PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_margin_top")] + public double? PdfMarginTop { get; set; } + + /// + /// Sets the orientation of the PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_orientation")] + public PdfOrientation? PdfOrientation { get; set; } + + /// + /// Sets the PDF page height, in pixels. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_page_height")] + public double? PdfPageHeight { get; set; } + + /// + /// Sets the PDF page range to return. By default, the page is split into a multi page + /// document and returns all page. Use this option to restrict which pages should be returned. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_page_range")] + public string PdfPageRange { get; set; } + + /// + /// Sets the PDF page size. Setting this option will take precedence over `pdf_page_width` + /// and `pdf_page_height`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_page_size")] + public PdfPageSize? PdfPageSize { get; set; } + + /// + /// Sets the PDF page width, in pixels. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_page_width")] + public double? PdfPageWidth { get; set; } + + /// + /// Sets the scale factor of the website content in the PDF. Valid values are numbers between + /// 0.1 and 2. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_scale")] + public double? PdfScale { get; set; } + + /// + /// Whether to show the default pdf footer on each page of the pdf. The template of the + /// footer can be changed by setting the [`pdf_footer`](#pdf_footer) option. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_show_footer")] + public bool? PdfShowFooter { get; set; } + + /// + /// Whether to show the default pdf header on each page of the pdf. The template of the + /// header can be changed by setting the [`pdf_header`](#pdf_header) option. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pdf_show_header")] + public bool? PdfShowHeader { get; set; } + + /// + /// Sets the `navigator.platform` that the browser will report for the request. Useful for + /// getting around certain scripts that detect the platform. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("platform")] + public string Platform { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("press_escape")] + public bool? PressEscape { get; set; } + + /// + /// Pass in a proxy server address to make screenshot requests via that server in the format + /// `[address]:[port]`. If proxy authentication is required, you can use the following + /// format: `[user]:[password]@[address]:[port]`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("proxy")] + public string Proxy { get; set; } + + /// + /// The image quality of the resulting screenshot (JPEG/WebP only) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("quality")] + public long? Quality { get; set; } + + /// + /// Make the pdf into a readable document by removing unnecessary elements such as navigation + /// bars, ads, etc. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("readable")] + public bool? Readable { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("rec2020")] + public bool? Rec2020 { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("redirect_after")] + public long? RedirectAfter { get; set; } + + /// + /// Prefer less animations on websites by setting `prefers-reduced-motion: reduced` + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("reduced_motion")] + public bool? ReducedMotion { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("refresh")] + public bool? Refresh { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("refresh_after_scroll")] + public bool? RefreshAfterScroll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("reload")] + public bool? Reload { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("reload_after_scroll")] + public bool? ReloadAfterScroll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("render_queue")] + public string RenderQueue { get; set; } + + /// + /// For render link requests, setting this option to `json` will change the response type of + /// the Urlbox request to JSON. For the API, the default response type is JSON. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("response_type")] + public ResponseType? ResponseType { get; set; } + + /// + /// Take a 'retina' or high-definition screenshot, equivalent to setting a device pixel ratio + /// of 2.0 or @2x. Please note that retina screenshots will be double the normal dimensions + /// and will normally take slightly longer to process due to the much bigger image size. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("retina")] + public bool? Retina { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("retry_on_nav_error")] + public bool? RetryOnNavError { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("revisit")] + public bool? Revisit { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("revisit_after_scroll")] + public bool? RevisitAfterScroll { get; set; } + + /// + /// Overrides the configured bucket to use when saving the render. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_bucket")] + public string S3Bucket { get; set; } + + /// + /// You can change the endpoint URL to use an S3 compatible storage provider e.g. + /// DigitalOcean Spaces, Minio, Wasabi, Cloudflare R2 and more. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_endpoint")] + public string S3Endpoint { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_force_path_style")] + public bool? S3ForcePathStyle { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_key")] + public string S3Key { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_object_lock")] + public bool? S3ObjectLock { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_part_size")] + public long? S3PartSize { get; set; } + + /// + /// Sets the S3 path, including subdirectories and the filename, to use when saving the + /// render in your S3-compatible bucket. ~> The extension (e.g. .png, .jpg or .pdf) will be + /// provided automatically, and should not be included in `s3_path`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_path")] + public string S3Path { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_presigned_url")] + public string S3PresignedUrl { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_presigned_url_html")] + public string S3PresignedUrlHtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_presigned_url_markdown")] + public string S3PresignedUrlMarkdown { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_presigned_url_metadata")] + public string S3PresignedUrlMetadata { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_presigned_url_mhtml")] + public string S3PresignedUrlMhtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_private_bucket")] + public bool? S3PrivateBucket { get; set; } + + /// + /// Override the configured S3 region when saving the render. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_region")] + public string S3Region { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_secret")] + public string S3Secret { get; set; } + + /// + /// Sets the s3 storage class. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("s3_storageclass")] + public S3Storageclass? S3Storageclass { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("save_html")] + public bool? SaveHtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("save_markdown")] + public bool? SaveMarkdown { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("save_metadata")] + public bool? SaveMetadata { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("save_mhtml")] + public bool? SaveMhtml { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scrgb_linear")] + public bool? ScrgbLinear { get; set; } + + /// + /// When Urlbox decides to split a screenshot into multiple sections, the scroll delay is the + /// time to wait between taking the screenshots of each individual section, in milliseconds. + /// While Urlbox does detect animations, and attempts to wait for them before taking a + /// screenshot, this option could be used to force Urlbox to wait for a certain amount of + /// time after scrolling to the next section, to wait for things like animations to finish. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scroll_delay")] + public long? ScrollDelay { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scroll_height")] + public long? ScrollHeight { get; set; } + + /// + /// Sets how many pixels to scroll when scrolling the page to trigger lazy loading elements. + /// By default, the scroll increment is set to the browser viewport height. Some pages' lazy + /// loading elements only trigger when the scroll increment is smaller than this, however, + /// e.g. 400px. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scroll_increment")] + public long? ScrollIncrement { get; set; } + + /// + /// Scroll, to either an element or to a pixel offset from the top, before taking a screenshot + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scroll_to")] + public string ScrollTo { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("scroll_to_x")] + public string ScrollToX { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("secure_mode")] + public bool? SecureMode { get; set; } + + /// + /// Take a screenshot of the element that matches this selector. By default, if the selector + /// is not found, Urlbox will take a normal viewport screenshot. If you prefer Urlbox to fail + /// the request when the selector is not found, pass `fail_if_selector_missing=true`. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("selector")] + public string Selector { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sharp_stitch")] + public bool? SharpStitch { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("show_seams")] + public bool? ShowSeams { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("show_sections")] + public bool? ShowSections { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("skip_final_delay")] + public bool? SkipFinalDelay { get; set; } + + /// + /// Enabling `skip_scroll` will speed up renders by skipping an initial scroll through the + /// page, which is used to trigger any lazy loading elements. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("skip_scroll")] + public bool? SkipScroll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("skip_webhooks")] + public bool? SkipWebhooks { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("solve_captchas")] + public bool? SolveCaptchas { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("solve_cf_turnstile")] + public bool? SolveCfTurnstile { get; set; } + + /// + /// The height of the generated thumbnail, in pixels. Omit for a full-size screenshot. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("thumb_height")] + public long? ThumbHeight { get; set; } + + /// + /// The width of the generated thumbnail, in pixels. Omit for a full-size screenshot. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("thumb_width")] + public long? ThumbWidth { get; set; } + + /// + /// The amount of time to wait for the requested URL to load, in milliseconds. The timeout + /// value needs to be between 5,000 and 100,000 milliseconds. The default is 30000 or 30 + /// seconds. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("timeout")] + public long? Timeout { get; set; } + + /// + /// If a website has no background color set, the image will have a transparent background + /// (PNG/WebP only) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("transparent")] + public bool? Transparent { get; set; } + + /// + /// The duration to keep a screenshot or PDF in the cache, in seconds. ttl stands for 'time + /// to live'. The default value is also the maximum value: `2592000` seconds (30 days). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("ttl")] + public long? Ttl { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("turbo")] + public bool? Turbo { get; set; } + + /// + /// Emulate the timezone to use when rendering pages. Example: `tz=Europe/London`. A list of + /// timezone ID's can be found here: + /// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("tz")] + public string Tz { get; set; } + + /// + /// Pass a unique string such as a UUID, hash or timestamp, to have more control over when to + /// generate a fresh screenshot or PDF. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("unique")] + public string Unique { get; set; } + + /// + /// The URL or domain of the website you want to screenshot. We will automatically prepend + /// `http://` if it is missing. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("use_chrome")] + public bool? UseChrome { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("use_chromium")] + public bool? UseChromium { get; set; } + + /// + /// Save the render directly to the S3 (or S3-Compatible) bucket configured on your account. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("use_s3")] + public bool? UseS3 { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("use_stealth")] + public bool? UseStealth { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("use_tailwind")] + public bool? UseTailwind { get; set; } + + /// + /// Sets the `User-Agent` string for the request The presets are: * `random` - Uses a random + /// user-agent to help avoid bot detection * `mobile` - Uses a 'mobile-like' user-agent + /// string * `desktop` - Uses a 'desktop' user-agent string This can be used in some cases to + /// emulate certain device types, like mobile phones or tablets. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("user_agent")] + public string UserAgent { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("v")] + public EngineVersion? V { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_aspect")] + public long? VideoAspect { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_bitrate")] + public long? VideoBitrate { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_bits_per_second")] + public long? VideoBitsPerSecond { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_chrome_height")] + public long? VideoChromeHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_codec")] + public VideoCodec? VideoCodec { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_crop_w")] + public long? VideoCropW { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_dont_scroll_back")] + public bool? VideoDontScrollBack { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_ease")] + public VideoEase? VideoEase { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_ease_end")] + public VideoEase? VideoEaseEnd { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_ffmpeg")] + public string[] VideoFfmpeg { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_fps")] + public long? VideoFps { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_height")] + public long? VideoHeight { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_jitter")] + public double? VideoJitter { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_method")] + public VideoMethod? VideoMethod { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_postscroll_duration")] + public long? VideoPostscrollDuration { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_prescroll_duration")] + public long? VideoPrescrollDuration { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_preset")] + public VideoPreset? VideoPreset { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_quality")] + public long? VideoQuality { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_refs")] + public long? VideoRefs { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_rest_duration")] + public long? VideoRestDuration { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_scroll")] + public bool? VideoScroll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_scroll_back_duration")] + public long? VideoScrollBackDuration { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_scroll_distance")] + public long? VideoScrollDistance { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_scroll_duration")] + public long? VideoScrollDuration { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_seek")] + public double? VideoSeek { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_time")] + public long? VideoTime { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_use_iscroll")] + public bool? VideoUseIscroll { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("video_width")] + public long? VideoWidth { get; set; } + + /// + /// Waits for the element specified by this selector to be present in the DOM before taking a + /// screenshot or PDF. By default, Urlbox will take a screenshot or PDF if the `wait_for` + /// element is not found after waiting for the time specified by the + /// [`wait_timeout`](#wait_timeout) option. If you prefer Urlbox to fail the request when the + /// `wait_for` element is not found, pass + /// [`fail_if_selector_missing=true`](#fail_if_selector_missing) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_for")] + public string WaitFor { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_for_x")] + public string WaitForX { get; set; } + + /// + /// The amount of time to wait for the [`wait_for`](#wait_for) element to appear, or the + /// [`wait_to_leave`](#wait_to_leave) element to leave before continuing, in milliseconds. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_timeout")] + public long? WaitTimeout { get; set; } + + /// + /// Waits for the element specified by this selector to be absent from the DOM before taking + /// a screenshot or PDF. A typical use-case would be waiting for loading spinners to be + /// absent before taking a screenshot. By default, Urlbox will take a screenshot or PDF if + /// the `wait_to_leave` element is still present after the time specified by the + /// [`wait_timeout`](#wait_timeout) option. If you prefer Urlbox to fail the request when + /// the `wait_to_leave` element is still present, pass + /// [`fail_if_selector_present=true`](#fail_if_selector_present) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_to_leave")] + public string WaitToLeave { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_to_leave_x")] + public string WaitToLeaveX { get; set; } + + /// + /// Waits until the specified DOM event has fired before capturing a render. The available + /// options are: * `domloaded` (the `DOMContentLoaded` event is fired) * + /// `mostrequestsfinished` (consider navigation to be finished when there are no more than 2 + /// network connections for at least 500 ms) * `requestsfinished` (there are no more than 0 + /// network connections for at least 500 ms) * `loaded` (the `load` event is fired) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wait_until")] + public WaitUntil? WaitUntil { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("watermark")] + public bool? Watermark { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("webhook_url")] + public string WebhookUrl { get; set; } + + /// + /// The viewport width of the browser, in pixels. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("width")] + public long? Width { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("width_from")] + public string WidthFrom { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("wrap")] + public string Wrap { get; set; } + } + + public enum ColorProfile { Colorspingamma24, Default, Dp3, Hdr10, Rec2020, Scrgblinear, Srgb }; + + /// + /// Sets the version of the urlbox rendering engine to use when rendering the page. This can + /// be useful for testing how a page will render in the latest version of our rendering + /// engine. + /// + public enum EngineVersion { Latest, Lts, Stable }; + + /// + /// The output format of the resulting render. + /// + public enum Format { Avif, Html, Jpeg, Jpg, Md, Mhtml, Mp4, Pdf, Png, Svg, Webm, Webp }; + + /// + /// Whether to use scroll and stitch algorithm (the default) to render a full page + /// screenshot, or to use the native full page screenshot algorithm, which is faster, but can + /// be less accurate on some sites. + /// + public enum FullPageMode { Native, Stitch }; + + /// + /// How the screenshot should be resized or cropped to fit the dimensions when using + /// [`thumb_width`](#thumb_width) and/or [`thumb_height`](#thumb_height) options + /// + public enum ImgFit { Contain, Cover, Fill, Inside, Outside }; + + /// + /// How the image should be positioned when using an [`img_fit`](#img_fit) of `cover` or + /// `contain`. + /// + public enum ImgPosition { Attention, Bottom, Center, Centre, East, Entropy, Left, LeftBottom, LeftTop, North, Northeast, Northwest, Right, RightBottom, RightTop, South, Southeast, Southwest, Top, West }; + + /// + /// By default, when generating a PDF, the `print` CSS media query is used. To generate a PDF + /// using the `screen` CSS, set this option to `screen`. + /// + public enum Media { Print, Screen }; + + /// + /// Sets the margin of the PDF document. + /// + public enum PdfMargin { Default, Minimum, None }; + + /// + /// Sets the orientation of the PDF. + /// + public enum PdfOrientation { Landscape, Portrait }; + + /// + /// Sets the PDF page size. Setting this option will take precedence over `pdf_page_width` + /// and `pdf_page_height`. + /// + public enum PdfPageSize { A0, A1, A2, A3, A4, A5, A6, Ledger, Legal, Letter, PdfPageSizeA0, PdfPageSizeA1, PdfPageSizeA2, PdfPageSizeA3, PdfPageSizeA4, PdfPageSizeA5, PdfPageSizeA6, PdfPageSizeLedger, PdfPageSizeLegal, PdfPageSizeLetter, PdfPageSizeTabloid, Tabloid }; + + /// + /// For render link requests, setting this option to `json` will change the response type of + /// the Urlbox request to JSON. For the API, the default response type is JSON. + /// + public enum ResponseType { Base64, Binary, Json, Jsondebug, None }; + + /// + /// Sets the s3 storage class. + /// + public enum S3Storageclass { DeepArchive, Glacier, IntelligentTiering, OnezoneIa, Outposts, ReducedRedundancy, S3StorageclassDeepArchive, S3StorageclassGlacier, S3StorageclassIntelligentTiering, S3StorageclassOnezoneIa, S3StorageclassOutposts, S3StorageclassReducedRedundancy, S3StorageclassStandard, S3StorageclassStandardIa, Standard, StandardIa }; + + public enum VideoCodec { H264, Vp8, Vp9 }; + + public enum VideoEase { BackIn, BackInout, BackOut, BounceIn, BounceInout, BounceOut, CircularIn, CircularInout, CircularOut, CubicIn, CubicInout, CubicOut, ElasticIn, ElasticInout, ElasticOut, ExponentialIn, ExponentialInout, ExponentialOut, LinearNone, QuadraticIn, QuadraticInout, QuadraticOut, QuarticIn, QuarticInout, QuarticOut, QuinticIn, QuinticInout, QuinticOut, SinusoidalIn, SinusoidalInout, SinusoidalOut }; + + public enum VideoMethod { Extension, Psr, Screencast }; + + public enum VideoPreset { Fast, Faster, Medium, Slow, Slower, Superfast, Ultrafast, Veryfast, Veryslow }; + + /// + /// Waits until the specified DOM event has fired before capturing a render. The available + /// options are: * `domloaded` (the `DOMContentLoaded` event is fired) * + /// `mostrequestsfinished` (consider navigation to be finished when there are no more than 2 + /// network connections for at least 500 ms) * `requestsfinished` (there are no more than 0 + /// network connections for at least 500 ms) * `loaded` (the `load` event is fired) + /// + public enum WaitUntil { Domloaded, Loaded, Mostrequestsfinished, Requestsfinished }; + + public partial class UrlboxOptions + { + public static UrlboxOptions FromJson(string json) => JsonSerializer.Deserialize(json, UrlboxSDK.Options.Resource.Converter.Settings); + } + + public static class Serialize + { + public static string ToJson(this UrlboxOptions self) => JsonSerializer.Serialize(self, UrlboxSDK.Options.Resource.Converter.Settings); + } + + internal static class Converter + { + public static readonly JsonSerializerOptions Settings = new(JsonSerializerDefaults.General) + { + Converters = + { + ColorProfileConverter.Singleton, + EngineVersionConverter.Singleton, + FormatConverter.Singleton, + FullPageModeConverter.Singleton, + ImgFitConverter.Singleton, + ImgPositionConverter.Singleton, + MediaConverter.Singleton, + PdfMarginConverter.Singleton, + PdfOrientationConverter.Singleton, + PdfPageSizeConverter.Singleton, + ResponseTypeConverter.Singleton, + S3StorageclassConverter.Singleton, + VideoCodecConverter.Singleton, + VideoEaseConverter.Singleton, + VideoMethodConverter.Singleton, + VideoPresetConverter.Singleton, + WaitUntilConverter.Singleton, + new DateOnlyConverter(), + new TimeOnlyConverter(), + IsoDateTimeOffsetConverter.Singleton + }, + }; + } + + internal class ColorProfileConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(ColorProfile); + + public override ColorProfile Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "colorspingamma24": + return ColorProfile.Colorspingamma24; + case "default": + return ColorProfile.Default; + case "dp3": + return ColorProfile.Dp3; + case "hdr10": + return ColorProfile.Hdr10; + case "rec2020": + return ColorProfile.Rec2020; + case "scrgblinear": + return ColorProfile.Scrgblinear; + case "srgb": + return ColorProfile.Srgb; + } + throw new Exception("Cannot unmarshal type ColorProfile"); + } + + public override void Write(Utf8JsonWriter writer, ColorProfile value, JsonSerializerOptions options) + { + switch (value) + { + case ColorProfile.Colorspingamma24: + JsonSerializer.Serialize(writer, "colorspingamma24", options); + return; + case ColorProfile.Default: + JsonSerializer.Serialize(writer, "default", options); + return; + case ColorProfile.Dp3: + JsonSerializer.Serialize(writer, "dp3", options); + return; + case ColorProfile.Hdr10: + JsonSerializer.Serialize(writer, "hdr10", options); + return; + case ColorProfile.Rec2020: + JsonSerializer.Serialize(writer, "rec2020", options); + return; + case ColorProfile.Scrgblinear: + JsonSerializer.Serialize(writer, "scrgblinear", options); + return; + case ColorProfile.Srgb: + JsonSerializer.Serialize(writer, "srgb", options); + return; + } + throw new Exception("Cannot marshal type ColorProfile"); + } + + public static readonly ColorProfileConverter Singleton = new ColorProfileConverter(); + } + + internal class EngineVersionConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(EngineVersion); + + public override EngineVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "latest": + return EngineVersion.Latest; + case "lts": + return EngineVersion.Lts; + case "stable": + return EngineVersion.Stable; + } + throw new Exception("Cannot unmarshal type EngineVersion"); + } + + public override void Write(Utf8JsonWriter writer, EngineVersion value, JsonSerializerOptions options) + { + switch (value) + { + case EngineVersion.Latest: + JsonSerializer.Serialize(writer, "latest", options); + return; + case EngineVersion.Lts: + JsonSerializer.Serialize(writer, "lts", options); + return; + case EngineVersion.Stable: + JsonSerializer.Serialize(writer, "stable", options); + return; + } + throw new Exception("Cannot marshal type EngineVersion"); + } + + public static readonly EngineVersionConverter Singleton = new EngineVersionConverter(); + } + + internal class FormatConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(Format); + + public override Format Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "avif": + return Format.Avif; + case "html": + return Format.Html; + case "jpeg": + return Format.Jpeg; + case "jpg": + return Format.Jpg; + case "md": + return Format.Md; + case "mhtml": + return Format.Mhtml; + case "mp4": + return Format.Mp4; + case "pdf": + return Format.Pdf; + case "png": + return Format.Png; + case "svg": + return Format.Svg; + case "webm": + return Format.Webm; + case "webp": + return Format.Webp; + } + throw new Exception("Cannot unmarshal type Format"); + } + + public override void Write(Utf8JsonWriter writer, Format value, JsonSerializerOptions options) + { + switch (value) + { + case Format.Avif: + JsonSerializer.Serialize(writer, "avif", options); + return; + case Format.Html: + JsonSerializer.Serialize(writer, "html", options); + return; + case Format.Jpeg: + JsonSerializer.Serialize(writer, "jpeg", options); + return; + case Format.Jpg: + JsonSerializer.Serialize(writer, "jpg", options); + return; + case Format.Md: + JsonSerializer.Serialize(writer, "md", options); + return; + case Format.Mhtml: + JsonSerializer.Serialize(writer, "mhtml", options); + return; + case Format.Mp4: + JsonSerializer.Serialize(writer, "mp4", options); + return; + case Format.Pdf: + JsonSerializer.Serialize(writer, "pdf", options); + return; + case Format.Png: + JsonSerializer.Serialize(writer, "png", options); + return; + case Format.Svg: + JsonSerializer.Serialize(writer, "svg", options); + return; + case Format.Webm: + JsonSerializer.Serialize(writer, "webm", options); + return; + case Format.Webp: + JsonSerializer.Serialize(writer, "webp", options); + return; + } + throw new Exception("Cannot marshal type Format"); + } + + public static readonly FormatConverter Singleton = new FormatConverter(); + } + + internal class FullPageModeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(FullPageMode); + + public override FullPageMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "native": + return FullPageMode.Native; + case "stitch": + return FullPageMode.Stitch; + } + throw new Exception("Cannot unmarshal type FullPageMode"); + } + + public override void Write(Utf8JsonWriter writer, FullPageMode value, JsonSerializerOptions options) + { + switch (value) + { + case FullPageMode.Native: + JsonSerializer.Serialize(writer, "native", options); + return; + case FullPageMode.Stitch: + JsonSerializer.Serialize(writer, "stitch", options); + return; + } + throw new Exception("Cannot marshal type FullPageMode"); + } + + public static readonly FullPageModeConverter Singleton = new FullPageModeConverter(); + } + + internal class ImgFitConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(ImgFit); + + public override ImgFit Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "contain": + return ImgFit.Contain; + case "cover": + return ImgFit.Cover; + case "fill": + return ImgFit.Fill; + case "inside": + return ImgFit.Inside; + case "outside": + return ImgFit.Outside; + } + throw new Exception("Cannot unmarshal type ImgFit"); + } + + public override void Write(Utf8JsonWriter writer, ImgFit value, JsonSerializerOptions options) + { + switch (value) + { + case ImgFit.Contain: + JsonSerializer.Serialize(writer, "contain", options); + return; + case ImgFit.Cover: + JsonSerializer.Serialize(writer, "cover", options); + return; + case ImgFit.Fill: + JsonSerializer.Serialize(writer, "fill", options); + return; + case ImgFit.Inside: + JsonSerializer.Serialize(writer, "inside", options); + return; + case ImgFit.Outside: + JsonSerializer.Serialize(writer, "outside", options); + return; + } + throw new Exception("Cannot marshal type ImgFit"); + } + + public static readonly ImgFitConverter Singleton = new ImgFitConverter(); + } + + internal class ImgPositionConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(ImgPosition); + + public override ImgPosition Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "attention": + return ImgPosition.Attention; + case "bottom": + return ImgPosition.Bottom; + case "center": + return ImgPosition.Center; + case "centre": + return ImgPosition.Centre; + case "east": + return ImgPosition.East; + case "entropy": + return ImgPosition.Entropy; + case "left": + return ImgPosition.Left; + case "left bottom": + return ImgPosition.LeftBottom; + case "left top": + return ImgPosition.LeftTop; + case "north": + return ImgPosition.North; + case "northeast": + return ImgPosition.Northeast; + case "northwest": + return ImgPosition.Northwest; + case "right": + return ImgPosition.Right; + case "right bottom": + return ImgPosition.RightBottom; + case "right top": + return ImgPosition.RightTop; + case "south": + return ImgPosition.South; + case "southeast": + return ImgPosition.Southeast; + case "southwest": + return ImgPosition.Southwest; + case "top": + return ImgPosition.Top; + case "west": + return ImgPosition.West; + } + throw new Exception("Cannot unmarshal type ImgPosition"); + } + + public override void Write(Utf8JsonWriter writer, ImgPosition value, JsonSerializerOptions options) + { + switch (value) + { + case ImgPosition.Attention: + JsonSerializer.Serialize(writer, "attention", options); + return; + case ImgPosition.Bottom: + JsonSerializer.Serialize(writer, "bottom", options); + return; + case ImgPosition.Center: + JsonSerializer.Serialize(writer, "center", options); + return; + case ImgPosition.Centre: + JsonSerializer.Serialize(writer, "centre", options); + return; + case ImgPosition.East: + JsonSerializer.Serialize(writer, "east", options); + return; + case ImgPosition.Entropy: + JsonSerializer.Serialize(writer, "entropy", options); + return; + case ImgPosition.Left: + JsonSerializer.Serialize(writer, "left", options); + return; + case ImgPosition.LeftBottom: + JsonSerializer.Serialize(writer, "left bottom", options); + return; + case ImgPosition.LeftTop: + JsonSerializer.Serialize(writer, "left top", options); + return; + case ImgPosition.North: + JsonSerializer.Serialize(writer, "north", options); + return; + case ImgPosition.Northeast: + JsonSerializer.Serialize(writer, "northeast", options); + return; + case ImgPosition.Northwest: + JsonSerializer.Serialize(writer, "northwest", options); + return; + case ImgPosition.Right: + JsonSerializer.Serialize(writer, "right", options); + return; + case ImgPosition.RightBottom: + JsonSerializer.Serialize(writer, "right bottom", options); + return; + case ImgPosition.RightTop: + JsonSerializer.Serialize(writer, "right top", options); + return; + case ImgPosition.South: + JsonSerializer.Serialize(writer, "south", options); + return; + case ImgPosition.Southeast: + JsonSerializer.Serialize(writer, "southeast", options); + return; + case ImgPosition.Southwest: + JsonSerializer.Serialize(writer, "southwest", options); + return; + case ImgPosition.Top: + JsonSerializer.Serialize(writer, "top", options); + return; + case ImgPosition.West: + JsonSerializer.Serialize(writer, "west", options); + return; + } + throw new Exception("Cannot marshal type ImgPosition"); + } + + public static readonly ImgPositionConverter Singleton = new ImgPositionConverter(); + } + + internal class MediaConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(Media); + + public override Media Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "print": + return Media.Print; + case "screen": + return Media.Screen; + } + throw new Exception("Cannot unmarshal type Media"); + } + + public override void Write(Utf8JsonWriter writer, Media value, JsonSerializerOptions options) + { + switch (value) + { + case Media.Print: + JsonSerializer.Serialize(writer, "print", options); + return; + case Media.Screen: + JsonSerializer.Serialize(writer, "screen", options); + return; + } + throw new Exception("Cannot marshal type Media"); + } + + public static readonly MediaConverter Singleton = new MediaConverter(); + } + + internal class PdfMarginConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(PdfMargin); + + public override PdfMargin Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "default": + return PdfMargin.Default; + case "minimum": + return PdfMargin.Minimum; + case "none": + return PdfMargin.None; + } + throw new Exception("Cannot unmarshal type PdfMargin"); + } + + public override void Write(Utf8JsonWriter writer, PdfMargin value, JsonSerializerOptions options) + { + switch (value) + { + case PdfMargin.Default: + JsonSerializer.Serialize(writer, "default", options); + return; + case PdfMargin.Minimum: + JsonSerializer.Serialize(writer, "minimum", options); + return; + case PdfMargin.None: + JsonSerializer.Serialize(writer, "none", options); + return; + } + throw new Exception("Cannot marshal type PdfMargin"); + } + + public static readonly PdfMarginConverter Singleton = new PdfMarginConverter(); + } + + internal class PdfOrientationConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(PdfOrientation); + + public override PdfOrientation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "landscape": + return PdfOrientation.Landscape; + case "portrait": + return PdfOrientation.Portrait; + } + throw new Exception("Cannot unmarshal type PdfOrientation"); + } + + public override void Write(Utf8JsonWriter writer, PdfOrientation value, JsonSerializerOptions options) + { + switch (value) + { + case PdfOrientation.Landscape: + JsonSerializer.Serialize(writer, "landscape", options); + return; + case PdfOrientation.Portrait: + JsonSerializer.Serialize(writer, "portrait", options); + return; + } + throw new Exception("Cannot marshal type PdfOrientation"); + } + + public static readonly PdfOrientationConverter Singleton = new PdfOrientationConverter(); + } + + internal class PdfPageSizeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(PdfPageSize); + + public override PdfPageSize Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "A0": + return PdfPageSize.PdfPageSizeA0; + case "A1": + return PdfPageSize.PdfPageSizeA1; + case "A2": + return PdfPageSize.PdfPageSizeA2; + case "A3": + return PdfPageSize.PdfPageSizeA3; + case "A4": + return PdfPageSize.PdfPageSizeA4; + case "A5": + return PdfPageSize.PdfPageSizeA5; + case "A6": + return PdfPageSize.PdfPageSizeA6; + case "Ledger": + return PdfPageSize.PdfPageSizeLedger; + case "Legal": + return PdfPageSize.PdfPageSizeLegal; + case "Letter": + return PdfPageSize.PdfPageSizeLetter; + case "Tabloid": + return PdfPageSize.PdfPageSizeTabloid; + case "a0": + return PdfPageSize.A0; + case "a1": + return PdfPageSize.A1; + case "a2": + return PdfPageSize.A2; + case "a3": + return PdfPageSize.A3; + case "a4": + return PdfPageSize.A4; + case "a5": + return PdfPageSize.A5; + case "a6": + return PdfPageSize.A6; + case "ledger": + return PdfPageSize.Ledger; + case "legal": + return PdfPageSize.Legal; + case "letter": + return PdfPageSize.Letter; + case "tabloid": + return PdfPageSize.Tabloid; + } + throw new Exception("Cannot unmarshal type PdfPageSize"); + } + + public override void Write(Utf8JsonWriter writer, PdfPageSize value, JsonSerializerOptions options) + { + switch (value) + { + case PdfPageSize.PdfPageSizeA0: + JsonSerializer.Serialize(writer, "A0", options); + return; + case PdfPageSize.PdfPageSizeA1: + JsonSerializer.Serialize(writer, "A1", options); + return; + case PdfPageSize.PdfPageSizeA2: + JsonSerializer.Serialize(writer, "A2", options); + return; + case PdfPageSize.PdfPageSizeA3: + JsonSerializer.Serialize(writer, "A3", options); + return; + case PdfPageSize.PdfPageSizeA4: + JsonSerializer.Serialize(writer, "A4", options); + return; + case PdfPageSize.PdfPageSizeA5: + JsonSerializer.Serialize(writer, "A5", options); + return; + case PdfPageSize.PdfPageSizeA6: + JsonSerializer.Serialize(writer, "A6", options); + return; + case PdfPageSize.PdfPageSizeLedger: + JsonSerializer.Serialize(writer, "Ledger", options); + return; + case PdfPageSize.PdfPageSizeLegal: + JsonSerializer.Serialize(writer, "Legal", options); + return; + case PdfPageSize.PdfPageSizeLetter: + JsonSerializer.Serialize(writer, "Letter", options); + return; + case PdfPageSize.PdfPageSizeTabloid: + JsonSerializer.Serialize(writer, "Tabloid", options); + return; + case PdfPageSize.A0: + JsonSerializer.Serialize(writer, "a0", options); + return; + case PdfPageSize.A1: + JsonSerializer.Serialize(writer, "a1", options); + return; + case PdfPageSize.A2: + JsonSerializer.Serialize(writer, "a2", options); + return; + case PdfPageSize.A3: + JsonSerializer.Serialize(writer, "a3", options); + return; + case PdfPageSize.A4: + JsonSerializer.Serialize(writer, "a4", options); + return; + case PdfPageSize.A5: + JsonSerializer.Serialize(writer, "a5", options); + return; + case PdfPageSize.A6: + JsonSerializer.Serialize(writer, "a6", options); + return; + case PdfPageSize.Ledger: + JsonSerializer.Serialize(writer, "ledger", options); + return; + case PdfPageSize.Legal: + JsonSerializer.Serialize(writer, "legal", options); + return; + case PdfPageSize.Letter: + JsonSerializer.Serialize(writer, "letter", options); + return; + case PdfPageSize.Tabloid: + JsonSerializer.Serialize(writer, "tabloid", options); + return; + } + throw new Exception("Cannot marshal type PdfPageSize"); + } + + public static readonly PdfPageSizeConverter Singleton = new PdfPageSizeConverter(); + } + + internal class ResponseTypeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(ResponseType); + + public override ResponseType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "base64": + return ResponseType.Base64; + case "binary": + return ResponseType.Binary; + case "json": + return ResponseType.Json; + case "jsondebug": + return ResponseType.Jsondebug; + case "none": + return ResponseType.None; + } + throw new Exception("Cannot unmarshal type ResponseType"); + } + + public override void Write(Utf8JsonWriter writer, ResponseType value, JsonSerializerOptions options) + { + switch (value) + { + case ResponseType.Base64: + JsonSerializer.Serialize(writer, "base64", options); + return; + case ResponseType.Binary: + JsonSerializer.Serialize(writer, "binary", options); + return; + case ResponseType.Json: + JsonSerializer.Serialize(writer, "json", options); + return; + case ResponseType.Jsondebug: + JsonSerializer.Serialize(writer, "jsondebug", options); + return; + case ResponseType.None: + JsonSerializer.Serialize(writer, "none", options); + return; + } + throw new Exception("Cannot marshal type ResponseType"); + } + + public static readonly ResponseTypeConverter Singleton = new ResponseTypeConverter(); + } + + internal class S3StorageclassConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(S3Storageclass); + + public override S3Storageclass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "DEEP_ARCHIVE": + return S3Storageclass.DeepArchive; + case "GLACIER": + return S3Storageclass.Glacier; + case "INTELLIGENT_TIERING": + return S3Storageclass.IntelligentTiering; + case "ONEZONE_IA": + return S3Storageclass.OnezoneIa; + case "OUTPOSTS": + return S3Storageclass.Outposts; + case "REDUCED_REDUNDANCY": + return S3Storageclass.ReducedRedundancy; + case "STANDARD": + return S3Storageclass.Standard; + case "STANDARD_IA": + return S3Storageclass.StandardIa; + case "deep_archive": + return S3Storageclass.S3StorageclassDeepArchive; + case "glacier": + return S3Storageclass.S3StorageclassGlacier; + case "intelligent_tiering": + return S3Storageclass.S3StorageclassIntelligentTiering; + case "onezone_ia": + return S3Storageclass.S3StorageclassOnezoneIa; + case "outposts": + return S3Storageclass.S3StorageclassOutposts; + case "reduced_redundancy": + return S3Storageclass.S3StorageclassReducedRedundancy; + case "standard": + return S3Storageclass.S3StorageclassStandard; + case "standard_ia": + return S3Storageclass.S3StorageclassStandardIa; + } + throw new Exception("Cannot unmarshal type S3Storageclass"); + } + + public override void Write(Utf8JsonWriter writer, S3Storageclass value, JsonSerializerOptions options) + { + switch (value) + { + case S3Storageclass.DeepArchive: + JsonSerializer.Serialize(writer, "DEEP_ARCHIVE", options); + return; + case S3Storageclass.Glacier: + JsonSerializer.Serialize(writer, "GLACIER", options); + return; + case S3Storageclass.IntelligentTiering: + JsonSerializer.Serialize(writer, "INTELLIGENT_TIERING", options); + return; + case S3Storageclass.OnezoneIa: + JsonSerializer.Serialize(writer, "ONEZONE_IA", options); + return; + case S3Storageclass.Outposts: + JsonSerializer.Serialize(writer, "OUTPOSTS", options); + return; + case S3Storageclass.ReducedRedundancy: + JsonSerializer.Serialize(writer, "REDUCED_REDUNDANCY", options); + return; + case S3Storageclass.Standard: + JsonSerializer.Serialize(writer, "STANDARD", options); + return; + case S3Storageclass.StandardIa: + JsonSerializer.Serialize(writer, "STANDARD_IA", options); + return; + case S3Storageclass.S3StorageclassDeepArchive: + JsonSerializer.Serialize(writer, "deep_archive", options); + return; + case S3Storageclass.S3StorageclassGlacier: + JsonSerializer.Serialize(writer, "glacier", options); + return; + case S3Storageclass.S3StorageclassIntelligentTiering: + JsonSerializer.Serialize(writer, "intelligent_tiering", options); + return; + case S3Storageclass.S3StorageclassOnezoneIa: + JsonSerializer.Serialize(writer, "onezone_ia", options); + return; + case S3Storageclass.S3StorageclassOutposts: + JsonSerializer.Serialize(writer, "outposts", options); + return; + case S3Storageclass.S3StorageclassReducedRedundancy: + JsonSerializer.Serialize(writer, "reduced_redundancy", options); + return; + case S3Storageclass.S3StorageclassStandard: + JsonSerializer.Serialize(writer, "standard", options); + return; + case S3Storageclass.S3StorageclassStandardIa: + JsonSerializer.Serialize(writer, "standard_ia", options); + return; + } + throw new Exception("Cannot marshal type S3Storageclass"); + } + + public static readonly S3StorageclassConverter Singleton = new S3StorageclassConverter(); + } + + internal class VideoCodecConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VideoCodec); + + public override VideoCodec Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "h264": + return VideoCodec.H264; + case "vp8": + return VideoCodec.Vp8; + case "vp9": + return VideoCodec.Vp9; + } + throw new Exception("Cannot unmarshal type VideoCodec"); + } + + public override void Write(Utf8JsonWriter writer, VideoCodec value, JsonSerializerOptions options) + { + switch (value) + { + case VideoCodec.H264: + JsonSerializer.Serialize(writer, "h264", options); + return; + case VideoCodec.Vp8: + JsonSerializer.Serialize(writer, "vp8", options); + return; + case VideoCodec.Vp9: + JsonSerializer.Serialize(writer, "vp9", options); + return; + } + throw new Exception("Cannot marshal type VideoCodec"); + } + + public static readonly VideoCodecConverter Singleton = new VideoCodecConverter(); + } + + internal class VideoEaseConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VideoEase); + + public override VideoEase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "back.in": + return VideoEase.BackIn; + case "back.inout": + return VideoEase.BackInout; + case "back.out": + return VideoEase.BackOut; + case "bounce.in": + return VideoEase.BounceIn; + case "bounce.inout": + return VideoEase.BounceInout; + case "bounce.out": + return VideoEase.BounceOut; + case "circular.in": + return VideoEase.CircularIn; + case "circular.inout": + return VideoEase.CircularInout; + case "circular.out": + return VideoEase.CircularOut; + case "cubic.in": + return VideoEase.CubicIn; + case "cubic.inout": + return VideoEase.CubicInout; + case "cubic.out": + return VideoEase.CubicOut; + case "elastic.in": + return VideoEase.ElasticIn; + case "elastic.inout": + return VideoEase.ElasticInout; + case "elastic.out": + return VideoEase.ElasticOut; + case "exponential.in": + return VideoEase.ExponentialIn; + case "exponential.inout": + return VideoEase.ExponentialInout; + case "exponential.out": + return VideoEase.ExponentialOut; + case "linear.none": + return VideoEase.LinearNone; + case "quadratic.in": + return VideoEase.QuadraticIn; + case "quadratic.inout": + return VideoEase.QuadraticInout; + case "quadratic.out": + return VideoEase.QuadraticOut; + case "quartic.in": + return VideoEase.QuarticIn; + case "quartic.inout": + return VideoEase.QuarticInout; + case "quartic.out": + return VideoEase.QuarticOut; + case "quintic.in": + return VideoEase.QuinticIn; + case "quintic.inout": + return VideoEase.QuinticInout; + case "quintic.out": + return VideoEase.QuinticOut; + case "sinusoidal.in": + return VideoEase.SinusoidalIn; + case "sinusoidal.inout": + return VideoEase.SinusoidalInout; + case "sinusoidal.out": + return VideoEase.SinusoidalOut; + } + throw new Exception("Cannot unmarshal type VideoEase"); + } + + public override void Write(Utf8JsonWriter writer, VideoEase value, JsonSerializerOptions options) + { + switch (value) + { + case VideoEase.BackIn: + JsonSerializer.Serialize(writer, "back.in", options); + return; + case VideoEase.BackInout: + JsonSerializer.Serialize(writer, "back.inout", options); + return; + case VideoEase.BackOut: + JsonSerializer.Serialize(writer, "back.out", options); + return; + case VideoEase.BounceIn: + JsonSerializer.Serialize(writer, "bounce.in", options); + return; + case VideoEase.BounceInout: + JsonSerializer.Serialize(writer, "bounce.inout", options); + return; + case VideoEase.BounceOut: + JsonSerializer.Serialize(writer, "bounce.out", options); + return; + case VideoEase.CircularIn: + JsonSerializer.Serialize(writer, "circular.in", options); + return; + case VideoEase.CircularInout: + JsonSerializer.Serialize(writer, "circular.inout", options); + return; + case VideoEase.CircularOut: + JsonSerializer.Serialize(writer, "circular.out", options); + return; + case VideoEase.CubicIn: + JsonSerializer.Serialize(writer, "cubic.in", options); + return; + case VideoEase.CubicInout: + JsonSerializer.Serialize(writer, "cubic.inout", options); + return; + case VideoEase.CubicOut: + JsonSerializer.Serialize(writer, "cubic.out", options); + return; + case VideoEase.ElasticIn: + JsonSerializer.Serialize(writer, "elastic.in", options); + return; + case VideoEase.ElasticInout: + JsonSerializer.Serialize(writer, "elastic.inout", options); + return; + case VideoEase.ElasticOut: + JsonSerializer.Serialize(writer, "elastic.out", options); + return; + case VideoEase.ExponentialIn: + JsonSerializer.Serialize(writer, "exponential.in", options); + return; + case VideoEase.ExponentialInout: + JsonSerializer.Serialize(writer, "exponential.inout", options); + return; + case VideoEase.ExponentialOut: + JsonSerializer.Serialize(writer, "exponential.out", options); + return; + case VideoEase.LinearNone: + JsonSerializer.Serialize(writer, "linear.none", options); + return; + case VideoEase.QuadraticIn: + JsonSerializer.Serialize(writer, "quadratic.in", options); + return; + case VideoEase.QuadraticInout: + JsonSerializer.Serialize(writer, "quadratic.inout", options); + return; + case VideoEase.QuadraticOut: + JsonSerializer.Serialize(writer, "quadratic.out", options); + return; + case VideoEase.QuarticIn: + JsonSerializer.Serialize(writer, "quartic.in", options); + return; + case VideoEase.QuarticInout: + JsonSerializer.Serialize(writer, "quartic.inout", options); + return; + case VideoEase.QuarticOut: + JsonSerializer.Serialize(writer, "quartic.out", options); + return; + case VideoEase.QuinticIn: + JsonSerializer.Serialize(writer, "quintic.in", options); + return; + case VideoEase.QuinticInout: + JsonSerializer.Serialize(writer, "quintic.inout", options); + return; + case VideoEase.QuinticOut: + JsonSerializer.Serialize(writer, "quintic.out", options); + return; + case VideoEase.SinusoidalIn: + JsonSerializer.Serialize(writer, "sinusoidal.in", options); + return; + case VideoEase.SinusoidalInout: + JsonSerializer.Serialize(writer, "sinusoidal.inout", options); + return; + case VideoEase.SinusoidalOut: + JsonSerializer.Serialize(writer, "sinusoidal.out", options); + return; + } + throw new Exception("Cannot marshal type VideoEase"); + } + + public static readonly VideoEaseConverter Singleton = new VideoEaseConverter(); + } + + internal class VideoMethodConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VideoMethod); + + public override VideoMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "extension": + return VideoMethod.Extension; + case "psr": + return VideoMethod.Psr; + case "screencast": + return VideoMethod.Screencast; + } + throw new Exception("Cannot unmarshal type VideoMethod"); + } + + public override void Write(Utf8JsonWriter writer, VideoMethod value, JsonSerializerOptions options) + { + switch (value) + { + case VideoMethod.Extension: + JsonSerializer.Serialize(writer, "extension", options); + return; + case VideoMethod.Psr: + JsonSerializer.Serialize(writer, "psr", options); + return; + case VideoMethod.Screencast: + JsonSerializer.Serialize(writer, "screencast", options); + return; + } + throw new Exception("Cannot marshal type VideoMethod"); + } + + public static readonly VideoMethodConverter Singleton = new VideoMethodConverter(); + } + + internal class VideoPresetConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VideoPreset); + + public override VideoPreset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "fast": + return VideoPreset.Fast; + case "faster": + return VideoPreset.Faster; + case "medium": + return VideoPreset.Medium; + case "slow": + return VideoPreset.Slow; + case "slower": + return VideoPreset.Slower; + case "superfast": + return VideoPreset.Superfast; + case "ultrafast": + return VideoPreset.Ultrafast; + case "veryfast": + return VideoPreset.Veryfast; + case "veryslow": + return VideoPreset.Veryslow; + } + throw new Exception("Cannot unmarshal type VideoPreset"); + } + + public override void Write(Utf8JsonWriter writer, VideoPreset value, JsonSerializerOptions options) + { + switch (value) + { + case VideoPreset.Fast: + JsonSerializer.Serialize(writer, "fast", options); + return; + case VideoPreset.Faster: + JsonSerializer.Serialize(writer, "faster", options); + return; + case VideoPreset.Medium: + JsonSerializer.Serialize(writer, "medium", options); + return; + case VideoPreset.Slow: + JsonSerializer.Serialize(writer, "slow", options); + return; + case VideoPreset.Slower: + JsonSerializer.Serialize(writer, "slower", options); + return; + case VideoPreset.Superfast: + JsonSerializer.Serialize(writer, "superfast", options); + return; + case VideoPreset.Ultrafast: + JsonSerializer.Serialize(writer, "ultrafast", options); + return; + case VideoPreset.Veryfast: + JsonSerializer.Serialize(writer, "veryfast", options); + return; + case VideoPreset.Veryslow: + JsonSerializer.Serialize(writer, "veryslow", options); + return; + } + throw new Exception("Cannot marshal type VideoPreset"); + } + + public static readonly VideoPresetConverter Singleton = new VideoPresetConverter(); + } + + internal class WaitUntilConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(WaitUntil); + + public override WaitUntil Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + switch (value) + { + case "domloaded": + return WaitUntil.Domloaded; + case "loaded": + return WaitUntil.Loaded; + case "mostrequestsfinished": + return WaitUntil.Mostrequestsfinished; + case "requestsfinished": + return WaitUntil.Requestsfinished; + } + throw new Exception("Cannot unmarshal type WaitUntil"); + } + + public override void Write(Utf8JsonWriter writer, WaitUntil value, JsonSerializerOptions options) + { + switch (value) + { + case WaitUntil.Domloaded: + JsonSerializer.Serialize(writer, "domloaded", options); + return; + case WaitUntil.Loaded: + JsonSerializer.Serialize(writer, "loaded", options); + return; + case WaitUntil.Mostrequestsfinished: + JsonSerializer.Serialize(writer, "mostrequestsfinished", options); + return; + case WaitUntil.Requestsfinished: + JsonSerializer.Serialize(writer, "requestsfinished", options); + return; + } + throw new Exception("Cannot marshal type WaitUntil"); + } + + public static readonly WaitUntilConverter Singleton = new WaitUntilConverter(); + } + + public class DateOnlyConverter : JsonConverter + { + private readonly string serializationFormat; + public DateOnlyConverter() : this(null) { } + + public DateOnlyConverter(string? serializationFormat) + { + this.serializationFormat = serializationFormat ?? "yyyy-MM-dd"; + } + + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return DateOnly.Parse(value!); + } + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(serializationFormat)); + } + + public class TimeOnlyConverter : JsonConverter + { + private readonly string serializationFormat; + + public TimeOnlyConverter() : this(null) { } + + public TimeOnlyConverter(string? serializationFormat) + { + this.serializationFormat = serializationFormat ?? "HH:mm:ss.fff"; + } + + public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return TimeOnly.Parse(value!); + } + + public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(serializationFormat)); + } + + internal class IsoDateTimeOffsetConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(DateTimeOffset); + + private const string DefaultDateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"; + + private DateTimeStyles _dateTimeStyles = DateTimeStyles.RoundtripKind; + private string? _dateTimeFormat; + private CultureInfo? _culture; + + public DateTimeStyles DateTimeStyles + { + get => _dateTimeStyles; + set => _dateTimeStyles = value; + } + + public string? DateTimeFormat + { + get => _dateTimeFormat ?? string.Empty; + set => _dateTimeFormat = (string.IsNullOrEmpty(value)) ? null : value; + } + + public CultureInfo Culture + { + get => _culture ?? CultureInfo.CurrentCulture; + set => _culture = value; + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + string text; + + + if ((_dateTimeStyles & DateTimeStyles.AdjustToUniversal) == DateTimeStyles.AdjustToUniversal + || (_dateTimeStyles & DateTimeStyles.AssumeUniversal) == DateTimeStyles.AssumeUniversal) + { + value = value.ToUniversalTime(); + } + + text = value.ToString(_dateTimeFormat ?? DefaultDateTimeFormat, Culture); + + writer.WriteStringValue(text); + } + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? dateText = reader.GetString(); + + if (string.IsNullOrEmpty(dateText) == false) + { + if (!string.IsNullOrEmpty(_dateTimeFormat)) + { + return DateTimeOffset.ParseExact(dateText, _dateTimeFormat, Culture, _dateTimeStyles); + } + else + { + return DateTimeOffset.Parse(dateText, Culture, _dateTimeStyles); + } + } + else + { + return default(DateTimeOffset); + } + } + + + public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter(); + } +} +#pragma warning restore CS8618 +#pragma warning restore CS8601 +#pragma warning restore CS8603 diff --git a/UrlboxSDK/Options/Resource/UrlboxOptionsConstructor.cs b/UrlboxSDK/Options/Resource/UrlboxOptionsConstructor.cs new file mode 100644 index 0000000..9088946 --- /dev/null +++ b/UrlboxSDK/Options/Resource/UrlboxOptionsConstructor.cs @@ -0,0 +1,44 @@ +namespace UrlboxSDK.Options.Resource +{ + using System; + /// + /// Constructor for UrlboxOptions. Allows QT to autogen type with no construct + /// + public partial class UrlboxOptions + { + // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + /* + All options get serialized as nullable due to the automated options properties having + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] above them. + + The warning CS8618 is suppressed because the compiler doesn't know that these fields + are intentionally left uninitialized in the constructor. The properties may be set later, + and `JsonIgnoreCondition.WhenWritingNull` ensures they won't cause issues if left as `null`. + + This behavior is acceptable because `UrlboxOptions` is designed to work with optional + properties that default to `null`. Since properties are optional and assigned later, + the warning can be safely ignored. + */ +#pragma warning disable CS8618 + public UrlboxOptions(string? url = null, string? html = null) +#pragma warning restore CS8618 + { + if ( + String.IsNullOrEmpty(url) && !String.IsNullOrEmpty(html) + ) + { + Html = html; + } + else if ( + !String.IsNullOrEmpty(url) && String.IsNullOrEmpty(html) + ) + { + Url = url; + } + else + { + throw new ArgumentException("Either but not both options 'url' or 'html' must be provided."); + } + } + } +} \ No newline at end of file diff --git a/UrlboxSDK/Options/Validation/UrlboxOptionsValidation.cs b/UrlboxSDK/Options/Validation/UrlboxOptionsValidation.cs new file mode 100644 index 0000000..52f2831 --- /dev/null +++ b/UrlboxSDK/Options/Validation/UrlboxOptionsValidation.cs @@ -0,0 +1,239 @@ +using System.Reflection; +using UrlboxSDK.Options.Resource; + +namespace UrlboxSDK.Options.Validation; + +public sealed class UrlboxOptionsValidation +{ + // ** Options that should not be applied if a given option is not set EG FullPage or UseS3 ** // + + /// + /// A list of options that can only be used if full_page = true + /// + private static readonly string[] FullPageOptions = + { + nameof(UrlboxOptions.FullPageMode), + nameof(UrlboxOptions.ScrollIncrement), + nameof(UrlboxOptions.ScrollDelay), + nameof(UrlboxOptions.DetectFullHeight), + nameof(UrlboxOptions.MaxSectionHeight), + nameof(UrlboxOptions.FullWidth) + }; + + /// + /// A list of options that can only be used if use_s3 = true + /// + private static readonly string[] S3Options = + { + nameof(UrlboxOptions.S3Bucket), + nameof(UrlboxOptions.S3Path), + nameof(UrlboxOptions.S3Endpoint), + nameof(UrlboxOptions.S3Region), + nameof(UrlboxOptions.S3Storageclass), + nameof(UrlboxOptions.CdnHost), + }; + + // Define PDF-specific options as a static readonly field + private static readonly string[] PdfOptions = + { + nameof(UrlboxOptions.PdfPageSize), + nameof(UrlboxOptions.PdfPageRange), + nameof(UrlboxOptions.PdfPageWidth), + nameof(UrlboxOptions.PdfPageHeight), + nameof(UrlboxOptions.PdfMargin), + nameof(UrlboxOptions.PdfMarginTop), + nameof(UrlboxOptions.PdfMarginRight), + nameof(UrlboxOptions.PdfMarginBottom), + nameof(UrlboxOptions.PdfMarginLeft), + nameof(UrlboxOptions.PdfAutoCrop), + nameof(UrlboxOptions.PdfScale), + nameof(UrlboxOptions.PdfOrientation), + nameof(UrlboxOptions.PdfBackground), + nameof(UrlboxOptions.DisableLigatures), + nameof(UrlboxOptions.Media), + nameof(UrlboxOptions.Readable), + nameof(UrlboxOptions.PdfShowHeader), + nameof(UrlboxOptions.PdfHeader), + nameof(UrlboxOptions.PdfShowFooter), + nameof(UrlboxOptions.PdfFooter) + }; + + /// + /// Determines if a value is considered "truthy" based on its type, + /// + /// Evaluates as truthy if: + /// - : Always falsy. + /// - : True if true. + /// - or : True if not zero. + /// - : True if not empty. + /// - : True if contains elements. + /// + /// Unhandled types are considered truthy. + /// + public static bool IsNullOption(object? value) + { + return value switch + { + // Filter out falsy values + null => false, + bool valueBool => valueBool, // Include only if true + int valueInt => valueInt != 0, // Include only if non-zero + long valueLong => valueLong != 0, // Include only if non-zero + double valueDouble => Math.Abs(valueDouble) >= double.Epsilon, // Include only if non-zero + string valueString => !string.IsNullOrEmpty(valueString), // Include only if not empty + string[] valueArray => valueArray.Length > 0 && !(valueArray[0] == ""), // Include only if array has elements + _ => true // Include all other non-handled types + }; + } + + /// + /// Publicly accessible validation method to ensure options are valid. + /// + /// + /// + public static UrlboxOptions Validate(UrlboxOptions options) + { + ValidateScreenshotOptions(options); + ValidatePdfOptions(options); + ValidateFullPageOptions(options); + ValidateS3Options(options); + return options; + } + + /// + /// Validates the provided . + /// + /// Validation Rules: + /// - If ImgFit is set, either ThumbWidth or ThumbHeight must be specified. + /// - If ImgPosition is set, ImgFit must also be set. + /// - If both ImgFit and ImgPosition are set, ImgFit must be "cover" or "contain". + /// + /// Throws: + /// - if validation rule is violated. + /// + /// Returns: + /// - The validated object if all checks pass. + /// + /// The instance to validate. + /// The validated instance. + private static UrlboxOptions ValidateScreenshotOptions(UrlboxOptions options) + { + bool thumbSizes = options.ThumbWidth != null || options.ThumbHeight != null; + bool hasImgFit = options.ImgFit != null && Enum.IsDefined(typeof(ImgFit), options.ImgFit); + bool hasImgPosition = options.ImgPosition != null && Enum.IsDefined(typeof(ImgPosition), options.ImgPosition); + bool imgFitIsCoverOrContain = options.ImgFit == UrlboxSDK.Options.Resource.ImgFit.Cover || options.ImgFit == UrlboxSDK.Options.Resource.ImgFit.Contain; + + if (!thumbSizes && hasImgFit) + { + throw new ArgumentException("Invalid Configuration: Image Fit is included despite ThumbWidth nor ThumbHeight being set."); + } + + if (!hasImgFit && hasImgPosition) + { + throw new ArgumentException("Invalid Configuration: Image Position is included despite Image Fit not being set."); + } + + if (hasImgFit && hasImgPosition && !imgFitIsCoverOrContain) + { + throw new ArgumentException("Invalid Configuration: Image Position is included despite Image Fit not being set to 'cover' or 'contain'."); + } + + return options; + } + + /// + /// Validates the provided . + /// + /// Validation Rules: + /// - If FullPage is not set to true, no full-page-specific options should be included. + /// + /// Throws: + /// - if full-page options are set when FullPage is false or not set. + /// + /// Returns: + /// - The validated instance. + /// + /// The instance to validate. + /// The validated instance. + private static UrlboxOptions ValidateFullPageOptions(UrlboxOptions options) + { + bool isNotFullPage = !options.FullPage.HasValue || (options.FullPage.HasValue && options.FullPage != true); + bool hasFullPageOptions = HasOptionsInCategory(FullPageOptions, options); + if ( + isNotFullPage && hasFullPageOptions + ) + { + throw new ArgumentException("Invalid configuration: Full-page options are included despite 'FullPage' being set to false."); + } + return options; + } + + /// + /// Validates the provided . + /// + /// Validation Rules: + /// - If UseS3 is not set to true, no S3-specific options should be included. + /// + /// Throws: + /// - if S3 options are set when UseS3 is false or not set. + /// + /// Returns: + /// - The validated instance. + /// + /// The instance to validate. + /// The validated instance. + private static UrlboxOptions ValidateS3Options(UrlboxOptions options) + { + bool isNotUsingS3 = !options.UseS3.HasValue || (options.UseS3.HasValue && options.UseS3 != true); + bool hasS3Options = HasOptionsInCategory(S3Options, options); + if (isNotUsingS3 && hasS3Options) + { + throw new ArgumentException("Invalid configuration: S3 options are included despite 'UseS3' being set to false."); + } + return options; + } + + /// + /// Validates the provided . + /// + /// Validation Rules: + /// - If Format is not set to Pdf, no PDF-specific options should be included. + /// + /// Throws: + /// - if PDF options are set when Format is not Pdf. + /// + /// Returns: + /// - The validated instance. + /// + /// The instance to validate. + /// The validated instance. + private static UrlboxOptions ValidatePdfOptions(UrlboxOptions options) + { + bool isNotUsingPdf = options.Format != UrlboxSDK.Options.Resource.Format.Pdf; + bool hasPdfOptions = HasOptionsInCategory(PdfOptions, options); + if (isNotUsingPdf && hasPdfOptions) + { + throw new ArgumentException("One or more PDF-specific options are only valid for the PDF format."); + } + + return options; + } + + /// + /// Determines if any properties in the specified category are set in the given options. + /// + /// Array of property names to check within the options. + /// The options object to inspect. + /// True if any property in the category is set; otherwise, false. + private static bool HasOptionsInCategory(string[] category, UrlboxOptions options) + { + return category + .Any(propertyName => + { + PropertyInfo? property = options.GetType().GetProperty(propertyName); + if (property == null) return false; + object? value = property.GetValue(options); + return UrlboxOptionsValidation.IsNullOption(value); + }); + } +} diff --git a/UrlboxSDK/Policy/SnakeCaseNamingPolicy.cs b/UrlboxSDK/Policy/SnakeCaseNamingPolicy.cs new file mode 100644 index 0000000..697649c --- /dev/null +++ b/UrlboxSDK/Policy/SnakeCaseNamingPolicy.cs @@ -0,0 +1,31 @@ +namespace UrlboxSDK.Policy; +/// +/// A custom naming policy for converting property names from PascalCase to snake_case +/// when serializing JSON. +/// +/// +/// This JsonNamingPolicy is included by default in .NET 8.0 (JsonNamingPolicy.SnakeCaseLower). +/// However, a custom implementation has been made here to maintain compatibility with .NET 6.0, +/// which is still under Long-Term Support (LTS). Keeping the SDK at 6.0 ensures broader accessibility +/// for audiences still using this version. +/// +/// +public sealed class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + // Insert underscores when: + // 1. Lowercase letter followed by an uppercase letter + // 2. Letter followed by a digit + // 3. Digit followed by a letter, but NOT when transitioning to "xx" or similar patterns + return string.Concat(name.Select((character, index) => + index > 0 && + ((char.IsLower(name[index - 1]) && char.IsUpper(character)) || // Lowercase followed by uppercase + (char.IsLetter(name[index - 1]) && char.IsDigit(character)) || // Letter followed by number + (char.IsDigit(name[index - 1]) && char.IsLetter(character) && // Number followed by letter + !(index + 1 < name.Length && char.IsLower(name[index + 1])))) // Exclude cases like '4xx' + ? "_" + character + : character.ToString())) + .ToLower(); + } +} \ No newline at end of file diff --git a/UrlboxSDK/README.md b/UrlboxSDK/README.md new file mode 100644 index 0000000..bd7dccc --- /dev/null +++ b/UrlboxSDK/README.md @@ -0,0 +1,964 @@ +[![image](../Images/urlbox-graphic.jpg)](https://www.urlbox.com) + +*** + +# The Urlbox .NET SDK + +The Urlbox .NET SDK provides easy access to the [Urlbox API](https://urlbox.com/) from your application. + +Just initialise Urlbox and generate a screenshot of a URL or HTML in no time. + +Check out our [blog](https://urlbox.com/blog) for more insights on everything screenshots and what we're doing. + +> **Note:** At Urlbox we make `Renders`. Typically, when we refer to a render here or anywhere else, we are referring to the entire process as a whole of taking your options, performing our magic, and sending back a screenshot your way. + +#### Checkout [OneMillionScreenshots](https://onemillionscreenshots.com/) - A site that uses Urlbox to show over 1 million of the web's homepages! +*** + + +# Table Of Contents + + +* [Documentation](#documentation) +* [Requirements](#requirements) +* [Installation](#installation) +* [Usage](#usage) + * [Start here](#start-here) + * [Getting Started - `TakeScreenshot()`](#getting-started---takescreenshot) + * [Configuring Options](#configuring-options-) + * [Using the options builder](#using-the-options-builder) + * [Using the `new` keyword, setting during initialization](#using-the-new-keyword-setting-during-initialization) + * [What to do if an option isn't available in the builder](#what-to-do-if-an-option-isnt-available-in-the-builder) + * [Render Links - `GenerateRenderLink()`](#render-links---generaterenderlink) + * [Sync Requests - `Render()`](#sync-requests---render) + * [Async Requests - `RenderAsync()`](#async-requests---renderasync) + * [Polling](#polling) + * [Webhooks](#webhooks) + * [Handling Errors](#handling-errors) + * [Dependency Injection](#dependency-injection) +* [Utility Functions](#utility-functions) + * [`TakeScreenshot(options)`](#takescreenshotoptions) + * [`TakePdf(options)`](#takepdfoptions) + * [`TakeMp4(options)`](#takemp4options) + * [`TakeScreenshotWithMetadata(options)`](#takescreenshotwithmetadataoptions) + * [`ExtractMetadata(options)`](#extractmetadataoptions) + * [`ExtractMarkdown(options)`](#extractmarkdownoptions) + * [`ExtractHtml(options)`](#extracthtmloptions) + * [`ExtractMhtml(options)`](#extractmhtmloptions) + * [`DownloadAsBase64(options)`](#downloadasbase64options-) + * [`DownloadToFile(options, filePath)`](#downloadtofileoptions-filepath-) + * [`GeneratePNGUrl(options)`](#generatepngurloptions-) + * [`GenerateJPEGUrl(options)`](#generatejpegurloptions-) + * [`GeneratePDFUrl(options)`](#generatepdfurloptions-) +* [Popular Use Cases](#popular-use-cases) + * [Taking a Full Page Screenshot](#taking-a-full-page-screenshot) + * [Example MP4 (Full Page)](#example-mp4--full-page-) + * [Taking a Mobile view screenshot](#taking-a-mobile-view-screenshot) + * [Failing a request on 4XX-5XX](#failing-a-request-on-4xx-5xx) + * [Extracting Markdown/Metadata/HTML](#extracting-markdownmetadatahtml) + * [Generating a Screenshot Using a Selector](#generating-a-screenshot-using-a-selector) + * [Uploading to the cloud via an S3 bucket](#uploading-to-the-cloud-via-an-s3-bucket) + * [Using a Proxy](#using-a-proxy) + * [Using Webhooks](#using-webhooks) + * [1. Visit your Urlbox dashboard, and get your Webhook Secret.](#1-visit-your-urlbox-dashboard-and-get-your-webhook-secret) + * [2. Create your Urlbox instance in your C# project:](#2-create-your-urlbox-instance-in-your-c-project) + * [3. Make a request through any of our rendering methods.](#3-make-a-request-through-any-of-our-rendering-methods-) + * [4. Verify that the webhook comes from Urlbox](#4-verify-that-the-webhook-comes-from-urlbox) +* [API Reference](#api-reference) + * [Urlbox API Reference](#urlbox-api-reference) + * [Constructor](#constructor) + * [Static Methods](#static-methods) + * [Screenshot and File Generation Methods](#screenshot-and-file-generation-methods) + * [Download and File Handling Methods](#download-and-file-handling-methods) + * [URL Generation Methods](#url-generation-methods) + * [Status and Validation Methods](#status-and-validation-methods) + * [Response Classes](#response-classes) + * [`SyncUrlboxResponse`](#syncurlboxresponse) + * [`AsyncUrlboxResponse`](#asyncurlboxresponse) + * [`WebhookUrlboxResponse`](#webhookurlboxresponse) + * [`UrlboxException`](#urlboxexception) + * [`UrlboxMetadata`](#urlboxmetadata) + * [Available Enums](#available-enums) + * [Examples](#examples) + * [Example HTML](#example-html) + * [Example PDF](#example-pdf) + * [Example PDF Highlighting](#example-pdf-highlighting) + * [Example PNG injecting Javascript](#example-png-injecting-javascript) + * [Feedback](#feedback) + * [Changelog](#changelog) + + +*** + +# Documentation + +See [here](https://urlbox.com/docs/overview) for the Urlbox API Docs. It includes an exhaustive list of all the options you could pass to our API, including what they do and example usage. + +We also have guides for how to set up uploading your final render to your own [S3](https://urlbox.com/docs/guides/s3) bucket, or use [proxies](https://urlbox.com/docs/guides/proxies) for geo-specific sites. + +# Requirements + +To use this SDK, you need .NET Core 6.0 or later. + +# Installation + +Nuget: + +```bash +dotnet add package urlbox.sdk.dotnet +``` + +# Usage + +## Start here + +Visit [Urlbox](https://urlbox.com) to sign up for a trial. You'll need to visit your [projects](https://urlbox.com/dashboard/projects) page, and gather your Publishable Key, Secret Key, and Webhook Secret key (if you intend on using webhooks). + +## Getting Started - `TakeScreenshot()` + +If you want something super simple, initialize an instance of Urlbox with the above credentials, then call our `TakeScreenshot(options)` method with the options of your choosing: + +```CS +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UrlboxSDK; // This is our package + +namespace MyNamespace +{ + class Program + { + static async Task Main() + { + // We highly recommend storing your Urlbox API key and secret somewhere secure. + string apiKey = Environment.GetEnvironmentVariable("URLBOX_API_KEY"); + string apiSecret = Environment.GetEnvironmentVariable("URLBOX_API_SECRET"); + string webhookSecret = Environment.GetEnvironmentVariable("URLBOX_WEBHOOK_SECRET"); + + // Create an instance of Urlbox and the Urlbox options you'd like to use + Urlbox urlbox = Urlbox.FromCredentials(apiKey, apiSecret, webhookSecret); + // Use the builder pattern for fluent options + UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com").Build(); + + // Take a screenshot - The default format is PNG + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + + // This is the URL destination where you can find your finalized render. + Console.Writeline(response.RenderUrl); + } + } +} +``` + +If you use the above with your own keys, it will give you back an object with a `renderUrl`. Making a GET request to that renderUrl will give you back a PNG back like this: + +![](../Images/urlbox-png.png) + +*** + +## Configuring Options + +Passing options are where the magic comes in. Options are simply extra inputs that we use to adapt the way we take the screenshot, or adapt any of the other steps involved in the rendering process. + +>**Note:** Almost all of our options are optional. However, you must at least provide a URL or some HTML in your options in order for us to know what we are rendering for you. + +You could, for example, change the way the request is made to your desired URL (like using a proxy server, passing in extra headers, an authorization token or some cookies), or change the way the page looks (like injecting Javascript, highlighting words, or making the background a tasteful fuchsia pink). + +There are a few ways to retrieve a screenshot from Urlbox, depending on how and when you need it. You could retrieve it as a [raw file](https://urlbox.com/docs/options#response_type) (using `UrlboxOptions.ResponseType(ResponseType.Binary)` ), or by default, as a JSON object with its size and stored location. + +There are a plethora of other options you can use. Checkout the [docs](https://urlbox.com/docs/overview) for more information. + +To initialise your urlbox options, we advise using the option builder. Start by calling the static method `Urlbox.Options()` with the URL or HTML you want to screenshot. + +The builder will validate your options on `.Build()`, and allow for a more readable/fluent interface in your code. + +### Using the options builder +```CS +using UrlboxSDK; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Response.Resource; + +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://urlbox.com" + ) + // Any Bool option sets to true when called with no arguments + .FullPage() + .Cookie("some=cookie", "someother=cookie") + .Gpu() + // Enumerables can be accessed/imported by their name: + .ResponseType(ResponseType.Json) + .BlockAds() + .HideCookieBanners() + .BlockUrls("https://ads.com", "https://trackers.com") + .Build(); + +AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + +Console.WriteLine(response.Status); +Console.WriteLine(response.RenderUrl); +``` + +You can alternatively set the Urlbox options with the `new` keyword. + +### Using the `new` keyword, setting during initialization + +We advise against using the `new` keyword. If you would like to anyway, here's an example: + +```CS +UrlboxOptions options = new(url: "https://urlbox.com") +{ + Format = Format.Pdf, + Gpu = true, + Retina = true, + DarkMode = true +}; + +// Or set them after init: +options.FullPage = true; + +AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); +``` + +### What to do if an option isn't available in the builder + +Our [latest](https://urlbox.com/docs/options#engine_version) engine is updated regularly, including new options which are released to better help you render screenshots. + +If you can't find an option within the builder, because our SDK isn't yet in sync with any latest changes, please do use our overloads for `render` and `renderAsync` which take an `IDictionary` instead of a `UrlboxOptions` type. + +Here's an example: + +```CS +IDictionary options = new Dictionary + { + { "click_accept", true }, + { "url", "https://urlbox.com" } + { "theOption", "YouCouldntFind" } + }; +SyncUrlboxResponse response = await urlbox.Render(options); + +Console.WriteLine(response); +``` +Please Bear in mind that this won't have the benefit of pre-validation. + +*** + +## Render Links - `GenerateRenderLink()` + +With Urlbox you can get a screenshot in a number of ways. It may seem a little complex at first, but each method has its purpose. + +Take a look at the [section in our docs](https://urlbox.com/docs/api/rest-api-vs-render-links#render-links) which explains the main benefits of using a render link over our `/sync` and `/async` methods. + +To get a render link, run the `GenerateRenderLink(options)` with your options. + +Once you have that render link, you're free to embed it anywhere you please. Make a GET request to that render link, and it will synchronously run a render, and return a screenshot. This is particularly handy for embedding into an `` tag. + +The method will, by default, sign the render link, for enhanced security. You can opt out of this by passing `urlbox.GenerateRenderLink(options, sign: false);` + +Here's an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://bbc.com" + ) + .Format(Format.Pdf) + .Build(); + +string renderLink = urlbox.GenerateRenderLink(options, sign: true); + +Console.WriteLine(renderLink); +``` + +## Sync Requests - `Render()` + +We have 2 other ways to get a screenshot from Urlbox, `render/sync` and `render/async`. + +Making a request to the [`/sync`](https://urlbox.com/docs/api#create-a-render-synchronously) endpoint means making a request that waits for your screenshot to be taken, and only then returns the response with your finished screenshot. You can achieve this by using the main `Render(options)` method. + +Here is an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://youtube.com" + ) + .Format(Format.Pdf) + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +If you haven't explicitly asked for a binary response in your options, you'll get a JSON 200 response like this: + +```JSON +{ + # Where the final screenshot is stored -- If you setup S3, it will be your bucket name / cdn host in the URL. + "renderUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.mp4", + # The size of the file in bytes + "size": 272154 +} +``` + +If you find that the kind of screenshot you are taking requires some time, and you don't want your network connection to be open for that long, the `/async` method may be better suited to your needs. Our `TakeScreenshot()` method already implements a polling mechanism using the `/async` endpoint and status checks, so you don't have to set one up yourself! + +*** + +## Async Requests - `RenderAsync()` + +Some renders can take some time to complete (think full page screenshots of infinitely scrolling sites, MP4 with retina level quality, or large full page PDF renders). + +If you anticipate your request being larger, then we would recommend using the [`/async`](https://urlbox.com/docs/api#create-a-render-asynchronously) endpoint by calling the `RenderAsync(options)` method or `TakeScreenshot(options)`. + +Here is an example of its usage: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://google.com" + ) + .Format(Format.Pdf) + .Build(); + +AsyncUrlboxResponse = await urlbox.RenderAsync(options); +``` + +This returns you: + +```JSON +{ + # When this is "succeeded", your render will be ready + "status": "created", + # This is your unique render id + "renderId": "fe7af5df-80e7-4b38-973a-005ebf06dabb", + # Make a GET to this to find out if your render is ready + "statusUrl": "https://api.urlbox.com/v1/render/fe7af5df-80e7-4b38-973a-005ebf06dabb" +} +``` + +You can find out _when_ your async render has been successfully made in two ways: + +### Polling + +You can [poll](https://en.wikipedia.org/wiki/Polling_(computer_science)) the `statusUrl` endpoint that comes back from the `/async` response via a GET request. The response from that status URL will include `"status": "succeeded"` when finished, as well as your final render URL. + +Use `TakeScreenshot()` to use our `/async` endpoint with a pre-built polling mechanism. The method will try for 60 seconds by default with an optional timeout. + +### Webhooks + +You can also use [webhooks](https://urlbox.com/docs/webhooks#using-webhooks) to tell you when your render is ready. Make a request to Urlbox, and we send the response as a POST request to an endpoint of your choosing. + +See the [Using Webhooks](#using-webhooks) section of these docs in for how to use webhooks with Urlbox in your application. + +## Handling Errors + +The SDK deserializes our API errors for you into an Exception class. + +The UrlboxException gives you some useful data. Here's an example: + +```CS +Urlbox urlbox = new(apiKey, apiSecret); + +UrlboxOptions options = Urlbox.Options( + url: "https://notaresolvableurlbox.com" + ) + .Build(); + +try +{ + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); +} +catch (UrlboxException exception) +{ + Console.WriteLine(exception.Message); // EG Invalid options, please check errors + Console.WriteLine(exception.Code); // EG InvalidOptions + Console.WriteLine(exception.Errors); // EG {"url":["error resolving URL - ENOTFOUND notresolvableurlbox.com"]} + Console.WriteLine(exception.RequestId); // EG 06u6e285-ahd3-45vc-ac8c-36b95e6c15b5 +} +``` + +The `Code` property will typically result in one of [these](https://urlbox.com/docs/api#error-codes). We're adding to this consistently to make you're error handling experience more streamlined. + +Got an unexpected 4XX or 5XX? You can ensure renders fail and don't count toward your render count for [non-2XX responses](#failing-a-request-on-4xx-5xx). + +## Dependency Injection + +We've set up an extension for DI. When you're configuring your DI you can run `services.AddUrlbox()` to define the Urlbox instance once. Here's a simple ASP.net app: + +```CS +using UrlboxSDK.DI.Extension; +using UrlboxSDK; +using UrlboxSDK.Response.Resource; + +var builder = WebApplication.CreateBuilder(args); + +// Add The Urlbox service to the service container +builder.Services.AddUrlbox(options => +{ + options.Key = "YOUR_API_KEY"; + options.Secret = "YOUR_SECRET"; + options.WebhookSecret = "YOUR-WEBHOOK-SECRET"; // Optional + options.BaseUrl = "https://api-eu.urlbox.com"; // Optional +}); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +// Urlbox gets injected from service container by reference to its interface +app.MapGet("/screenshot", async (HttpContext context, IUrlbox urlbox) => +{ + var options = Urlbox.Options(url: "https://example.com").Build(); + try + { + AsyncUrlboxResponse response = await urlbox.TakeScreenshot(options); + return Results.Json(new { message = "Screenshot generated!", response }); + } + catch (Exception ex) + { + return Results.Json(new { message = "Failed to generate screenshot", error = ex.Message }); + } +}); + +app.Run(); +``` + +*** + +# Utility Functions + +To make capturing and rendering screenshots even simpler, we’ve created several methods for common scenarios. Use these methods to quickly generate specific types of screenshots or files based on your needs: + +### `TakeScreenshot(options)` +Our simplest method to take a screenshot. Uses the `/async` Urlbox endpoint, and polls until the render is ready to reduce the time network requests stay open. + +### `TakePdf(options)` +Convert any URL or HTML into a PDF. + +### `TakeMp4(options)` +Turn any URL or HTML into an MP4 video. For a scrolling effect over the entire page, set `FullPage = true` to capture the full length of the content. + +### `TakeScreenshotWithMetadata(options)` +Takes a screenshot of any URL or HTML, bringing back a [UrlboxMetadata](#urlboxmetadata) object too with more information about the site. + +### `ExtractMetadata(options)` +Takes a screenshot of any URL or HTML, but extracts only the metadata from the render. Useful when you only need the `UrlboxMetadata` object from the render. + +### `ExtractMarkdown(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted markdown file as a string. + +### `ExtractHtml(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted HTML file as a string. + +### `ExtractMhtml(options)` +Takes a screenshot of any URL or HTML, downloads it and gives back the extracted MHTML file as a string. + +### `DownloadAsBase64(options)` +Gets a render link, runs a GET to that link to render your screenshot, then downloads the screenshot file as a Base64 string. + +### `DownloadToFile(options, filePath)` +Gets a render link, runs a GET to that link to render your screenshot, then downloads and stores the screenshot to the given filePath. + +### `GeneratePNGUrl(options)` +Gets a render link for a screenshot in PNG format. + +### `GenerateJPEGUrl(options)` +Gets a render link for a screenshot in JPEG format. + +### `GeneratePDFUrl(options)` +Gets a render link for a screenshot in PDF format. + +# Popular Use Cases + +## Taking a Full Page Screenshot + +Want to take a screenshot of the full page from top to bottom? + +For almost all formats, this is available by simply running a request with the full page option + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .FullPage() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This will generate you a tall render, from the top to the bottom of the page. + +For video renders, there a bit more to it. To simply take a video of the website scrolling from top to bottom run a request like this: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com") + .Format(Format.Mp4) + .FullPage() + .VideoScroll() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` +This will render you a full page MP4 as the example below shows: + +### [Example MP4 (Full Page)](../Assets/mp4.mp4) + +## Taking a Mobile view screenshot + +You may want to take a screenshot of a website/HTML as though it were being accessed from a mobile device. + +To achieve this you can simply change the width of the viewport to suit your needs. Here's an example for mobile: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://urlbox.com") + .Width(375) + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +Which should render you something like the below example: + +![](/Assets/mobile.png) + +## Failing a request on 4XX-5XX + +By default, Urlbox treats HTTP responses with status codes in the 400-599 range as successful renders, counting them toward your total render count. + +This feature enables you to capture screenshots of error responses when needed. If you prefer your render requests to fail when the response falls within this range, you can configure this behavior by passing `FailOn4xx()` and/or `FailOn5xx` as such: + +```CS +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .FailOn4xx() + .FailOn5xx() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This can save you renders over the month, particularly when tackling websites like tricky social media pages. + +If there is a failure, it will give you back a [UrlboxException](#urlboxexception). + +## Extracting Markdown/Metadata/HTML + +In addition to your main render format for your URL/HTML, you can additionally render and save the same screenshot as HTML, Markdown and/or Metadata in the same request. + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options( + url: "https://urlbox.com" + ) + .Format(Format.Pdf) + .SaveMarkdown() // This saves the same URL/HTML's content as a markdown file + .SaveHtml() // This saves the same URL/HTML's content as its HTML + .SaveMetadata() // This extracts the metadata, saves it and sends it back in the response. + .Metadata() // This extracts the metadata from the URL/HTML, and sends it back in the response without saving it to the cloud. + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +The JSON response is deserialized and turned into the SyncUrlboxResponse. The JSON response would look like this: + +```JSON +{ + "renderUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.pdf", + "size": 1048576, + "htmlUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.html", + "metadataUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.json", + "markdownUrl": "https://renders.urlbox.com/ub-temp-renders/renders/662facc1f3b58e0a6df7a98b/2024/10/23/1b4df8c9-f347-4661-9b6a-1c969beb7522.md", + "metadata": { + "title": "Example Page", + "description": "This is an example of metadata information.", + "screenshot_date": "2024-11-06T12:34:56Z", + "file_size": 1048576, + "mime_type": "application/pdf" + } +} +``` + +When using the screenshot and file generation methods from our SDK like `TakeScreenshot()`, `Render()` or `RenderAsync()`, responses will all be turned into a readable class instance for you, being either the `SyncUrlboxResponse` or `AsyncUrlboxResponse` for 200's. + +When downloading metadata, you can opt to either save the metadata, or just return it in the JSON response as above. Our helper method `TakeScreenshotWithMetadata()` will not store the metadata so not produce a URL. It will instead only return the metadata object as above. + +## Generating a Screenshot Using a Selector + +There are times when you don't want to screenshot the entirety of a website. You may want to avoid manual cropping after taking your screenshot. You can take a screenshot of only the elements that you wish to using the selector. + +Here's an example of using the selector option with our `Render(options)` method: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://github.com") + .Selector(".octicon-mark-github") + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +This will take the ID selector ".octicon-mark-github", and return a screenshot that looks like this: + +![](../Images/gh.png) + +## Uploading to the cloud via an S3 bucket + +For a typical render, we do the storing for you. You can opt to save the final screenshot to your own cloud provider. + +We would _**highly**_ recommend you follow our S3 setup instructions. Setting up a cloud bucket can be tedious at the best of times, so [this](https://urlbox.com/docs/storage/configure-s3) part of our docs can help untangle the process. + +In theory, we support any S3 compatible provider, though we have tested the following providers: + +- BackBlaze B2 +- AWS S3 +- Cloudflare R2 +- Google Cloud Storage +- Digital Ocean Spaces + +If there's another cloud provider you would like to use, please try to reach out to us if you're struggling to get setup. + +We allow for public CDN hosts, private buckets and buckets with object locking enabled. + +Once you've set up your bucket, you can simply add `UrlboxOptions.UseS3()` to your options before making your request. + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .UseS3() + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +## Using a Proxy + +Proxies can really help get past issues like rendering social media sites, or sites that track your origin. We have a great piece in our [docs](https://urlbox.com/docs/guides/proxies) to get you started. + +Simply pass in the proxy providers' details once you're set up, and we will make the request through that proxy. Here's an example: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + +UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .Proxy("http://brd-customer-hl_1a2b3c4d-zone-social_networks:ttpg162fe6e2@brd.superproxy.io:22225") + .Build(); + +SyncUrlboxResponse response = await urlbox.Render(options); +``` + +## Using Webhooks + +Webhooks are awesome. They save you time, money and headaches, and can quite equally cause just as many setting them up. Setting up a webhook with Urlbox has some optional steps, but we recommend you take them all for the most security. + +Please look at our example directory in the repo. + +### 1. Visit your Urlbox dashboard, and get your Webhook Secret. + +Go to your [projects](https://urlbox.com/dashboard/projects) page, select a project (you may only have one if you're just starting out with Urlbox), and copy the webhook secret key. + +### 2. Create your Urlbox instance in your C# project: + +```CS +Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); +``` + +### 3. Make a request through any of our rendering methods. + +The most common use case for a webhook is when you need to use the `/async` endpoint to handle a larger render. + +If you're developing locally, we would recommend using a service like [ngrok](https://ngrok.com/), and setting your webhook URL in the options to that ngrok endpoint. + +After you've added the endpoint, for example at the endpoint `/webhooks/urlbox`, make a request to that endpoint like this: + +```CS +static async Task Main() +{ + Urlbox urlbox = Urlbox.FromCredentials("YOUR_KEY", "YOUR_SECRET", "YOUR_WEBHOOK_SECRET"); + + UrlboxOptions options = Urlbox.Options(url: "https://google.com") + .WebhookUrl("https://myapp.com/webhooks/urlbox") + .Build(); + + SyncUrlboxResponse response = await urlbox.Render(options); +} +``` + +### 4. Verify that the webhook comes from Urlbox + +Once you have made your request, you should see it come in as a POST request to the endpoint you've made in your app for the webhook. The body should look like this: + +```JSON +{ + "event": "render.succeeded", + "renderId": "19a59ab6-a5aa-4cde-86cb-d2b23302fd84", + "result": { + "renderUrl": "https://renders.urlbox.com/urlbox1/renders/6215a3df94d7588f7d910513/2024/1/11/19a59ab6-a5aa-4cde-86cb-d2b23302fd84.png", + "size": 34097 + }, + "meta": { + "startTime": "2024-01-11T17:49:18.593Z", + "endTime": "2024-01-11T17:49:21.103Z" + } +} +``` + +There will also be our header `X-Urlbox-Signature` that should have a value like this: `t={timestamp},sha256={token}`. + +Extract both the header and the content, and pass it into `Urlbox.VerifyWebhookSignature(header, content)`, which, if successful, will return you a [WebhookUrlboxResponse](#webhookurlboxresponse). + +Please see the `Example` project in this repo which should help you get started. + +--- + +# API Reference + +Below is a brief description of every publicly available method our SDK provides: + +## Urlbox API Reference + +### Constructor +- **`Urlbox(string key, string secret, string webhookSecret = null)`** + Initializes a new instance of the Urlbox class with the provided API credentials and optional webhook secret. + +--- + +### Static Methods +- **`static Urlbox FromCredentials(string apiKey, string apiSecret, string webhookSecret)`** + Creates a new instance of the Urlbox class using the specified API key, secret, and optional webhook secret. + +- **`static UrlboxOptionsBuilder Options(string? url = null, string? html = null)`** + Creates a new instance of the Urlbox options builder. Requires a URL or HTML in the constructor to get started. + +--- + +### Screenshot and File Generation Methods + +- **`Task TakeScreenshot(UrlboxOptions options);`** +- **`Task TakeScreenshot(UrlboxOptions options, int timeout);`** + Takes a screenshot asynchronously with a polling mechanism. Optional timeout to dictate when to stop polling. + +- **`Task TakePdf(UrlboxOptions options);`** + Asynchronously generates a PDF based on the specified options. + +- **`Task TakeMp4(UrlboxOptions options);`** + Generates an MP4 video asynchronously using the specified options. + +- **`Task TakeFullPageScreenshot(UrlboxOptions options);`** + Captures a full-page screenshot asynchronously with the given options. + +- **`Task TakeMobileScreenshot(UrlboxOptions options);`** + Takes a mobile-optimized screenshot asynchronously based on the specified options. + +- **`Task TakeScreenshotWithMetadata(UrlboxOptions options);`** + Asynchronously takes a screenshot and includes metadata in the response. + +- **`Task Render(UrlboxOptions options);`** +- **`Task Render(IDictionary options);`** + Sends a synchronous request to generate a render with the provided options, returning a direct response. + +- **`Task RenderAsync(UrlboxOptions options);`** +- **`Task RenderAsync(IDictionary options);`** + Sends an asynchronous render request, providing a status URL for polling until completion. + +--- + +### Download and File Handling Methods + +- **`Task DownloadAsBase64(UrlboxOptions options, string format = "png", bool sign = true);`** + Downloads a screenshot as a Base64-encoded string in the specified format. Optional format and whether to sign the render link. + +- **`Task DownloadAsBase64(string urlboxUrl);`** + Downloads the screenshot from the provided URL as a Base64-encoded string. + +- **`Task DownloadToFile(string urlboxUrl, string filename);`** + Downloads a screenshot from the URL and saves it to the specified file path. + +- **`Task DownloadToFile(UrlboxOptions options, string filename, string format = "png", bool sign = true);`** + Generates a screenshot based on options, then downloads and saves it as a file. Optional format and whether to sign the render link + +--- + +### URL Generation Methods + +- **`string GeneratePNGUrl(UrlboxOptions options, bool sign = true);`** + Generates a PNG URL based on the specified screenshot options. + +- **`string GenerateJPEGUrl(UrlboxOptions options, bool sign = true);`** + Creates a JPEG URL using the provided rendering options. + +- **`string GeneratePDFUrl(UrlboxOptions options, bool sign = true);`** + Generates a PDF URL for the specified screenshot options. + +- **`string GenerateRenderLink(UrlboxOptions options, string format = "png", bool sign = true);`** + Constructs an Urlbox URL for the specified format and options. + +- **`string GenerateSignedRenderLink(UrlboxOptions options, string format = "png");`** + Constructs an Urlbox URL for the specified format and options signed with the consumer's secret token. + +--- + +### Status and Validation Methods + +- **`Task GetStatus(string renderId);`** + Retrieves the current status of an asynchronous render request. + +- **`bool VerifyWebhookSignature(string header, string content);`** + Verifies that a webhook signature originates from Urlbox using the configured webhook secret. + + +### Response Classes + +When using the SDK, our deserializers will take the JSON response from any POST to the API and turn them into one of the following: + +#### `SyncUrlboxResponse` + +Properties: + +- **`RenderUrl`** - The URL to run a GET request to in order to access your final render. +- **`Size`** - The size of the render in bytes. +- **`HtmlUrl`** - The URL to run a GET request to in order to access your final render as HTML. +- **`MhtmlUrl`** - The URL to run a GET request to in order to access your final render as MHTML. +- **`MetadataUrl`** - The URL to run a GET request to in order to access your final render as Metadata (JSON). +- **`MarkdownUrl`** - The URL to run a GET request to in order to access your final render as Markdown. +- **`Metadata`** - The Metadata object describing the rendered website. + +#### `AsyncUrlboxResponse` + +Properties: + +- **`Status`** - One of `waiting`, `active`, `failed`, `delayed`, `succeeded`. +- **`RenderId`** - The unique ID of the render request. +- **`StatusUrl`** - The URL to run a GET request to in order to find out if the render completed. +- **`Size`** - The size of the render in bytes. +- **`RenderUrl`** - The URL to run a GET request to in order to access your final render. +- **`HtmlUrl`** - The URL to run a GET request to in order to access your final render as HTML. +- **`MhtmlUrl`** - The URL to run a GET request to in order to access your final render as MHTML. +- **`MetadataUrl`** - The URL to run a GET request to in order to access your final render as Metadata (JSON). +- **`MarkdownUrl`** - The URL to run a GET request to in order to access your final render as Markdown. +- **`Metadata`** - The Metadata object describing the rendered website. + +#### `WebhookUrlboxResponse` + +Properties: + +- **`Event`** - The event that happened to the render EG "render.succeeded" +- **`RenderId`** - The unique ID of the render request. +- **`Error`** - The error from Urlbox, showing the code, message and any errors +- **`Result`** - An instance of the SyncUrlboxResponse +- **`Meta`** - Includes the start and end times for the render + +#### `UrlboxException` + +Properties: + +- **`RequestId`** - The unique ID of the render request. +- **`Code`** - The error code for the request. See a list [here](https://urlbox.com/docs/api#error-codes). +- **`Errors`** - A more detailed list of errors that occurred in the request. + +#### `UrlboxMetadata` + +Properties: + +- **`UrlRequested`** - The original URL requested for rendering. +- **`UrlResolved`** - The final resolved URL after any redirects. +- **`Url`** - The canonical URL of the rendered page. +- **`Author`** - The author of the content, if available. +- **`Date`** - The publication date of the content, if available. +- **`Description`** - The meta description of the page. +- **`Image`** - The primary image of the page, if available. +- **`Logo`** - The logo associated with the page or publisher. +- **`Publisher`** - The name of the publisher of the content. +- **`Title`** - The title of the page. +- **`OgTitle`** - The Open Graph title of the page. +- **`OgImages`** - A list of Open Graph images found on the page. +- **`OgDescription`** - The Open Graph description of the page. +- **`OgUrl`** - The Open Graph URL of the page. +- **`OgType`** - The Open Graph type of the page (e.g., article, website). +- **`OgSiteName`** - The Open Graph site name of the page. +- **`OgLocale`** - The locale specified by Open Graph metadata. +- **`Charset`** - The character encoding used by the page. +- **`TwitterCard`** - The Twitter card type for the page. +- **`TwitterSite`** - The Twitter site associated with the page. +- **`TwitterCreator`** - The Twitter creator associated with the page. + +### Available Enums + +There are a number of options which are one of a select few. We have made enums for these, which can be accessed directly from the UrlboxOptions namespace: + +ColorProfile - one of `Colorspingamma24`, `Default`, `Dp3`, `Hdr10`, `Rec2020`, `Scrgblinear`, `Srgb` + +EngineVersion - one of `Latest`, `Lts`, `Stable` + +Format - one of `Avif`, `Html`, `Jpeg`, `Jpg`, `Md`, `Mhtml`, `Mp4`, `Pdf`, `Png`, `Svg`, `Webm`, `Webp` + +FullPageMode - one of `Native`, `Stitch` + +ImgFit - one of `Contain`, `Cover`, `Fill`, `Inside`, `Outside` + +ImgPosition - one of `Attention`, `Bottom`, `Center`, `Centre`, `East`, `Entropy`, `Left`, `LeftBottom`, `LeftTop`, `North`, `Northeast`, `Northwest`, `Right`, `RightBottom`, `RightTop`, `South`, `Southeast`, `Southwest`, `Top`, `West` + +Media - one of `Print`, `Screen` + +PdfMargin - one of `Default`, `Minimum`, `None` + +PdfOrientation - one of `Landscape`, `Portait` + +PdfPageSize - one of `A0`, `A1`, `A2`, `A3`, `A4`, `A5`, `A6`, `Ledger`, `Legal`, `Letter`, `PdfPageSizeA0`, `PdfPageSizeA1`, `PdfPageSizeA2`, `PdfPageSizeA3`, `PdfPageSizeA4`, `PdfPageSizeA5`, `PdfPageSizeA6`, `PdfPageSizeLedger`, `PdfPageSizeLegal`, `PdfPageSizeLetter`, `PdfPageSizeTabloid`, `Tabloid` + +ResponseType - one of `Base64`, `Binary`, `Json`, `Jsondebug`, `None` + +S3Storageclass - one of `DeepArchive`, `Glacier`, `IntelligentTiering`, `OnezoneIa`, `Outposts`, `ReducedRedundancy`, `S3StorageclassDeepArchive`, `S3StorageclassGlacier`, `S3StorageclassIntelligentTiering`, `S3StorageclassOnezoneIa`, `S3StorageclassOutposts`, `S3StorageclassReducedRedundancy`, `S3StorageclassStandard`, `S3StorageclassStandardIa`, `Standard`, `StandardIa` + +VideoCodec - one of `H264`, `Vp8`, `Vp9` + +VideoEase - one of `BackIn`, `BackInout`, `BackOut`, `BounceIn`, `BounceInout`, `BounceOut`, `CircularIn`, `CircularInout`, `CircularOut`, `CubicIn`, `CubicInout`, `CubicOut`, `ElasticIn`, `ElasticInout`, `ElasticOut`, `ExponentialIn`, `ExponentialInout`, `ExponentialOut`, `LinearNone`, `QuadraticIn`, `QuadraticInout`, `QuadraticOut`, `QuarticIn`, `QuarticInout`, `QuarticOut`, `QuinticIn`, `QuinticInout`, `QuinticOut`, `SinusoidalIn`, `SinusoidalInout`, `SinusoidalOut` + +VideoMethod - one of `Extension`, `Psr`, `Screencast` + +VideoPreset - one of `Fast`, `Faster`, `Medium`, `Slow`, `Slower`, `Superfast`, `Ultrafast`, `Veryfast`, `Veryslow` + +WaitUntil - one of `Domloaded`, `Loaded`, `Mostrequestsfinished`, `Requestsfinished` + +## Examples + +### [Example HTML](../Assets/html.html) +### [Example PDF](../Assets/pdf.pdf) +### [Example PDF Highlighting](../Assets/highlight.pdf) +### [Example PNG injecting Javascript](../Assets/javascript.png) + +## Feedback + +We hope that the above has given you enough of an understanding to suit your use case. + +If you are still struggling, spot a bug, or have any suggestions, feel free to contact us at: `support@urlbox.com` or use our chat function on [our website](https://urlbox.com/). + +Get rendering! + +## Changelog + +- 2.0.0 - Major overhaul - **Non-backward compatible changes included.** + - Introduced fluent options builder with input validation. + - Introduced options as a typed class. + - Introduced webhook validation logic. + - Upgraded test suite. + - Created interfaces for DI. + - Introduced post sync and async methods. + - Introduced helper methods for common use cases. + - Overhauled readme including an API reference. + - Introduced logic and classes for side renders (save_html etc). + - Introduced classes for different response types from urlbox api. + - Added overhauls for render/renderAsync which take IDictionary for future proofing. + - Overhauls readme. + +Methods in previous versions of this SDK that would accept a Dictionary now take a standardised `UrlboxOptions` type. + +- 1.0.2 - Further Updates to readme. + +- 1.0.1 - Update Readme to replace instances of .io with .com. + +- 1.0.0 - First release! diff --git a/UrlboxSDK/Response/Resource/AbstractUrlboxResponse.cs b/UrlboxSDK/Response/Resource/AbstractUrlboxResponse.cs new file mode 100644 index 0000000..031014a --- /dev/null +++ b/UrlboxSDK/Response/Resource/AbstractUrlboxResponse.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using UrlboxSDK.Metadata.Resource; + +namespace UrlboxSDK.Response.Resource; +/// +/// abstract class for Urlbox response types. +/// +public abstract class AbstractUrlboxResponse +{ + protected const string EXTENSION_HTML = ".html"; + protected const string EXTENSION_MHTML = ".mhtml"; + protected const string EXTENSION_MARKDOWN = ".md"; + protected const string EXTENSION_METADATA = ".json"; + + /// + /// Checks that a given URL has its relevant file extension + /// + /// URL to check + /// Expected file extension + /// Validated URL + /// Thrown if URL does not contain expected extension + protected string CheckExtension(string url, string extension) + { + if (!url.Contains(extension)) + { + throw new ArgumentException($"The URL {url} does not contain the extension {extension}"); + } + return url; + } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? HtmlUrl { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MhtmlUrl { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MetadataUrl { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MarkdownUrl { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UrlboxMetadata? Metadata { get; } + + [JsonConstructor] + protected AbstractUrlboxResponse( + string? htmlUrl = null, + string? mhtmlUrl = null, + string? metadataUrl = null, + string? markdownUrl = null, + UrlboxMetadata? metadata = null + ) + { + HtmlUrl = string.IsNullOrEmpty(htmlUrl) ? null : CheckExtension(htmlUrl, EXTENSION_HTML); + MhtmlUrl = string.IsNullOrEmpty(mhtmlUrl) ? null : CheckExtension(mhtmlUrl, EXTENSION_MHTML); + MetadataUrl = string.IsNullOrEmpty(metadataUrl) ? null : CheckExtension(metadataUrl, EXTENSION_METADATA); + MarkdownUrl = string.IsNullOrEmpty(markdownUrl) ? null : CheckExtension(markdownUrl, EXTENSION_MARKDOWN); + Metadata = metadata; + } +} + diff --git a/UrlboxSDK/Response/Resource/AsyncUrlboxResponse.cs b/UrlboxSDK/Response/Resource/AsyncUrlboxResponse.cs new file mode 100644 index 0000000..79628b1 --- /dev/null +++ b/UrlboxSDK/Response/Resource/AsyncUrlboxResponse.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using UrlboxSDK.Metadata.Resource; + +namespace UrlboxSDK.Response.Resource; + +/// +/// Represents an asynchronous Urlbox response. +/// +public sealed class AsyncUrlboxResponse : AbstractUrlboxResponse +{ + public string Status { get; } // EG 'succeeded' + public string RenderId { get; } // A UUID for the request + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StatusUrl { get; } // A url which you can poll to check the render's status + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RenderUrl { get; } // only on status succeeded + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Size { get; } // only on status succeeded + + [JsonConstructor] + public AsyncUrlboxResponse( + string status, + string renderId, + string statusUrl, + int? size = null, + string? renderUrl = null, + string? htmlUrl = null, + string? mhtmlUrl = null, + string? metadataUrl = null, + string? markdownUrl = null, + UrlboxMetadata? metadata = null + ) : base(htmlUrl, mhtmlUrl, metadataUrl, markdownUrl, metadata) + { + Status = status; + RenderId = renderId; + StatusUrl = statusUrl; + Size = size; + if (!String.IsNullOrEmpty(renderUrl)) RenderUrl = renderUrl; + } +} diff --git a/UrlboxSDK/Response/Resource/ErrorUrlboxResponse.cs b/UrlboxSDK/Response/Resource/ErrorUrlboxResponse.cs new file mode 100644 index 0000000..b46ba5a --- /dev/null +++ b/UrlboxSDK/Response/Resource/ErrorUrlboxResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace UrlboxSDK.Response.Resource; + +public sealed class ErrorUrlboxResponse +{ + [JsonPropertyName("error")] + public UrlboxError Error { get; } + + [JsonPropertyName("requestId")] + public string RequestId { get; } + + [JsonConstructor] + public ErrorUrlboxResponse(UrlboxError error, string requestId) + { + Error = error; + RequestId = requestId; + } + + public sealed class UrlboxError + { + [JsonPropertyName("message")] + public string Message { get; } + + [JsonPropertyName("code")] + public string? Code { get; } + + [JsonPropertyName("errors")] + public string? Errors { get; } + + [JsonConstructor] + public UrlboxError(string message, string? code, string? errors) + { + Message = message; + Code = code; + Errors = errors; + } + } +} \ No newline at end of file diff --git a/UrlboxSDK/Response/Resource/SyncUrlboxResponse.cs b/UrlboxSDK/Response/Resource/SyncUrlboxResponse.cs new file mode 100644 index 0000000..74c0f8e --- /dev/null +++ b/UrlboxSDK/Response/Resource/SyncUrlboxResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using UrlboxSDK.Metadata.Resource; + +namespace UrlboxSDK.Response.Resource; + +/// +/// Represents a synchronous Urlbox response. +/// +public sealed class SyncUrlboxResponse : AbstractUrlboxResponse +{ + /// + /// The location of the screenshot + /// + public string RenderUrl { get; } + /// + /// The size of the screenshot in bytes + /// + public int Size { get; } + + [JsonConstructor] + public SyncUrlboxResponse( + string renderUrl, + int size, + string? htmlUrl = null, + string? mhtmlUrl = null, + string? metadataUrl = null, + string? markdownUrl = null, + UrlboxMetadata? metadata = null + ) : base(htmlUrl, mhtmlUrl, metadataUrl, markdownUrl, metadata) + { + RenderUrl = renderUrl; + Size = size; + } +} \ No newline at end of file diff --git a/UrlboxSDK/Urlbox.cs b/UrlboxSDK/Urlbox.cs new file mode 100644 index 0000000..cf2f62e --- /dev/null +++ b/UrlboxSDK/Urlbox.cs @@ -0,0 +1,693 @@ +using System.Net.Http; +using System.Threading.Tasks; +using System.Collections.Generic; +using UrlboxSDK.Exception; +using UrlboxSDK.Options.Builder; +using UrlboxSDK.Options.Resource; +using UrlboxSDK.Policy; +using UrlboxSDK.Factory; +using UrlboxSDK.Webhook.Resource; +using UrlboxSDK.Webhook.Validator; +using UrlboxSDK.Response.Resource; +using System.Diagnostics.CodeAnalysis; +using UrlboxSDK.Metadata.Resource; +using System.Reflection; +using UrlboxSDK.Config.Resource; + +namespace UrlboxSDK; +/// +/// Initializes a new instance of the class with the provided API key and secret. +/// +/// Your Urlbox.com API Key. +/// Your Urlbox.com API Secret. +/// Your Urlbox.com webhook Secret. +/// Thrown when the API key or secret is invalid. +public sealed partial class Urlbox : IUrlbox +{ + private static readonly string fullVersion = Assembly.GetExecutingAssembly() + .GetCustomAttribute()? + .InformationalVersion ?? "Unknown"; + + private readonly string version = fullVersion.Split('+')[0]; // Trim the commit hash if present + + private readonly UrlboxConfig urlboxConfig; + private readonly RenderLinkFactory renderLinkFactory; + private readonly UrlboxWebhookValidator? urlboxWebhookValidator; + private readonly HttpClient httpClient; + public const string DOMAIN = "urlbox.com"; + public const string BASE_URL = "https://api." + DOMAIN; + private const string SYNC_ENDPOINT = "/v1/render/sync"; + private const string ASYNC_ENDPOINT = "/v1/render/async"; + private const string STATUS_ENDPOINT = "/v1/render"; + public const int DEFAULT_TIMEOUT = 60000; // 60 seconds + + /// + /// Static function to build the UrlboxOptions + /// + /// + /// + /// + public static UrlboxOptionsBuilder Options( + string? url = null, + string? html = null + ) => new(url, html); + + public Urlbox(UrlboxConfig config) + { + config.Validate(); + urlboxConfig = config; + httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"Urlbox.net/{version}"); + renderLinkFactory = new RenderLinkFactory(config.Key!, config.Secret!); + + if (!string.IsNullOrEmpty(config.WebhookSecret)) + { + urlboxWebhookValidator = new UrlboxWebhookValidator(config.WebhookSecret); + } + } + + // Internal constructor (testable, allows injecting dependencies to mock http) + [ExcludeFromCodeCoverage] + internal Urlbox(UrlboxConfig config, RenderLinkFactory renderLinkFactory, HttpClient httpClient) + { + config.Validate(); + urlboxConfig = config; + this.renderLinkFactory = renderLinkFactory ?? throw new ArgumentNullException(nameof(renderLinkFactory)); + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + + if (!string.IsNullOrEmpty(config.WebhookSecret)) + { + urlboxWebhookValidator = new UrlboxWebhookValidator(config.WebhookSecret); + } + } + + // PUBLIC + + // ** Screenshot and File Generation Methods ** + + /// + /// A simple method which takes a screenshot of a website. + /// + /// + /// + /// A + /// + public async Task TakeScreenshot(UrlboxOptions options) + { + return await TakeScreenshotAsyncWithTimeout(options, DEFAULT_TIMEOUT); + } + + /// + /// A simple method which takes a screenshot of a website. + /// Set the timeout to stop polling Urlbox at a specified time, ensuring the screenshot was successfully captured. + /// + /// + /// + /// A + /// + public async Task TakeScreenshot(UrlboxOptions options, int timeout) + { + if (timeout > 120000 || timeout < 5000) + { + throw new TimeoutException("Invalid Timeout Length. Must be between 5000 (5 seconds) and 120000 (2 minutes)."); + } + return await TakeScreenshotAsyncWithTimeout(options, timeout); + } + + /// + /// Takes a screenshot async as a PDF + /// + /// + /// A + public async Task TakePdf(UrlboxOptions options) + { + options.Format = Format.Pdf; + return await TakeScreenshot(options); + } + + /// + /// Takes a screenshot async as an MP4 + /// + /// + /// A + public async Task TakeMp4(UrlboxOptions options) + { + options.Format = Format.Mp4; + return await TakeScreenshot(options); + } + + /// + /// Sends a synchronous render request to the Urlbox API and returns the rendered screenshot url and size. + /// + /// An instance of that contains the options for the render request. + /// A containing the result of the render request. + /// Thrown when the response is of an asynchronous type, indicating an incorrect endpoint was called. + /// + /// This method makes an HTTP POST request to the v1/render/sync endpoint, expecting a synchronous response. + /// + public async Task Render(UrlboxOptions options) + { + AbstractUrlboxResponse result = await MakeUrlboxPostRequest(SYNC_ENDPOINT, options); + return result switch + { + SyncUrlboxResponse syncResponse => syncResponse, + _ => throw new System.Exception("Response expected from .Render was one of SyncUrlboxResponse."), + }; + } + + /// + /// Sends a synchronous render request to the Urlbox API and returns the rendered screenshot url and size. + /// + /// The configuration options for the API request. + /// A containing the result of the render request. + /// Thrown when the response is of an asynchronous type, indicating an incorrect endpoint was called. + /// + /// This method makes an HTTP POST request to the v1/render/sync endpoint, expecting a synchronous response. + /// + public async Task Render(IDictionary options) + { + AbstractUrlboxResponse result = await MakeUrlboxPostRequest(SYNC_ENDPOINT, options); + return result switch + { + SyncUrlboxResponse syncResponse => syncResponse, + _ => throw new System.Exception("Response expected from .Render was one of SyncUrlboxResponse."), + }; + } + + /// + /// Sends an asynchronous render request to the Urlbox API and returns the status of the render request, as + /// well as a renderId and a statusUrl which can be polled to find out when the render succeeds. + /// + /// An instance of that contains the options for the render request. + /// A containing the result of the asynchronous render request, including the statusUrl, status and renderId. + /// Thrown when the response is of a synchronous type, indicating an incorrect endpoint was called. + /// + /// This method makes an HTTP POST request to the /render/async endpoint, expecting an asynchronous response. + /// + public async Task RenderAsync(UrlboxOptions options) + { + AbstractUrlboxResponse result = await MakeUrlboxPostRequest(ASYNC_ENDPOINT, options); + return result switch + { + AsyncUrlboxResponse asyncResponse => asyncResponse, + _ => throw new System.Exception("Response expected from .Render was one of AsyncUrlboxResponse."), + }; + } + + /// + /// Sends an asynchronous render request to the Urlbox API and returns the status of the render request, as + /// well as a renderId and a statusUrl which can be polled to find out when the render succeeds. + /// + /// The configuration options for the API request. + /// A containing the result of the asynchronous render request, including the statusUrl, status and renderId. + /// Thrown when the response is of a synchronous type, indicating an incorrect endpoint was called. + /// + /// This method makes an HTTP POST request to the /render/async endpoint, expecting an asynchronous response. + /// + public async Task RenderAsync(IDictionary options) + { + AbstractUrlboxResponse result = await MakeUrlboxPostRequest(ASYNC_ENDPOINT, options); + return result switch + { + AsyncUrlboxResponse asyncResponse => asyncResponse, + _ => throw new System.Exception("Response expected from .Render was one of AsyncUrlboxResponse."), + }; + } + + /// + /// Takes a screenshot async, requesting metadata about the page aside from the main render + /// + /// + /// A + public async Task TakeScreenshotWithMetadata(UrlboxOptions options) + { + options.Metadata = true; + return await TakeScreenshot(options); + } + + // ** Extraction Methods ** + + /// + /// Takes a screenshot async, extracting only the metadata about the page, rather than the render itself. + /// + /// + /// A + + public async Task ExtractMetadata(UrlboxOptions options) + { + options.Metadata = true; + AsyncUrlboxResponse response = await TakeScreenshot(options); + if (response.Metadata == null) + { + throw new System.Exception("Could not extract metadata from response."); + } + return response.Metadata; + } + + /// + /// Takes a screenshot async, extracting only the markdown and returning it as a string + /// + /// + public async Task ExtractMarkdown(UrlboxOptions options) + { + options.Format = Format.Md; + AsyncUrlboxResponse response = await TakeScreenshot(options); + if (response == null || response.RenderUrl == null) + { + throw new System.Exception("Could not extract markdown from result, no render URL."); + } + return await Download( + response.RenderUrl, + async result => await result.Content.ReadAsStringAsync() + ); + } + + /// + /// Takes a screenshot async, extracting only the HTML and returning it as a string + /// + /// + public async Task ExtractHtml(UrlboxOptions options) + { + options.Format = Format.Html; + AsyncUrlboxResponse response = await TakeScreenshot(options); + if (response == null || response.RenderUrl == null) + { + throw new System.Exception("Could not extract HTML from result, no render URL."); + } + return await Download( + response.RenderUrl, + async result => await result.Content.ReadAsStringAsync() + ); + } + + /// + /// Takes a screenshot async, extracting only the MHTML and returning it as a string + /// + /// + public async Task ExtractMhtml(UrlboxOptions options) + { + options.Format = Format.Html; + AsyncUrlboxResponse response = await TakeScreenshot(options); + if (response == null || response.RenderUrl == null) + { + throw new System.Exception("Could not extract MHTML from result, no render URL."); + } + return await Download( + response.RenderUrl, + async result => await result.Content.ReadAsStringAsync() + ); + } + + // ** Download and File Handling Methods ** + + /// + /// Downloads a screenshot as a Base64-encoded string from a Urlbox render link. + /// + /// The options for the screenshot + /// The image format (e.g., "png" ). + /// A Base64-encoded string of the screenshot. + public async Task DownloadAsBase64(UrlboxOptions options, bool sign = true) + { + if ( + options.ResponseType != null && + (options.ResponseType != ResponseType.Binary || options.ResponseType != ResponseType.Base64) + ) + { + options.ResponseType = ResponseType.Base64; + } + string urlboxUrl = GenerateRenderLink(options, sign); + return await DownloadAsBase64(urlboxUrl); + } + + /// + /// Downloads a screenshot as a Base64-encoded string from the given Urlbox URL. + /// + /// The render link Urlbox URL. + /// A Base64-encoded string of the screenshot. + public async Task DownloadAsBase64(string urlboxUrl) + { + static async Task onSuccess(HttpResponseMessage result) + { + byte[] bytes = await result.Content.ReadAsByteArrayAsync(); + IEnumerable contentType = result.Content.Headers.ToDictionary(l => l.Key, k => k.Value)["Content-Type"]; + string base64 = contentType.First() + ";base64," + Convert.ToBase64String(bytes); + return base64; + } + return await Download(urlboxUrl, onSuccess); + } + + /// + /// Downloads a screenshot and saves it as a file. + /// + /// The options for the screenshot. + /// The file path where the screenshot will be saved. + /// The image format (e.g., "png"). Default is "png". + /// The contents of the downloaded file as a string. + public async Task DownloadToFile(UrlboxOptions options, string filename, bool sign = true) + { + if ( + options.ResponseType != null && + (options.ResponseType != ResponseType.Binary || options.ResponseType != ResponseType.Base64) + ) + { + options.ResponseType = ResponseType.Base64; + } + string urlboxUrl = GenerateRenderLink(options, sign); + return await DownloadToFile(urlboxUrl, filename); + } + + /// + /// Downloads a screenshot from the given Urlbox URL and saves it as a file. + /// + /// The render link Urlbox URL. + /// The file path where the screenshot will be saved. + /// The contents of the downloaded file. + public async Task DownloadToFile(string urlboxUrl, string filename) + { + async Task onSuccess(HttpResponseMessage result) + { + using ( + Stream contentStream = await result.Content.ReadAsStreamAsync(), + stream = new FileStream(filename, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await contentStream.CopyToAsync(stream); + } + return await result.Content.ReadAsStringAsync(); + } + return await Download(urlboxUrl, onSuccess); + } + + // ** URL Generation Methods ** + + /// + /// Generates a URL for a PNG screenshot using the provided options. + /// + /// The options for the screenshot. + /// A render link Url to render a PNG screenshot. + public string GeneratePNGUrl(UrlboxOptions options, bool sign = true) + { + options.Format = Format.Png; + return GenerateRenderLink(options, sign); + } + + /// + /// Generates a URL for a JPEG screenshot using the provided options. + /// + /// The options for the screenshot. + /// A render link Url to render a JPEG screenshot. + public string GenerateJPEGUrl(UrlboxOptions options, bool sign = true) + { + options.Format = Format.Jpeg; + return GenerateRenderLink(options, sign); + } + + /// + /// Generates a URL for a PDF file using the provided options. + /// + /// The options for generating the PDF. + /// A render link Url to render a PDF file. + public string GeneratePDFUrl(UrlboxOptions options, bool sign = true) + { + options.Format = Format.Pdf; + return GenerateRenderLink(options, sign); + } + + /// + /// Generates a Urlbox URL with the specified format. + /// + /// The options for generating the screenshot or PDF. + /// The format of the output, e.g., "png", "jpg", "pdf". + /// A render link URL to render the content. + public string GenerateRenderLink(UrlboxOptions options, bool sign = true) + { + return renderLinkFactory.GenerateRenderLink(urlboxConfig.BaseUrl, options, sign); + } + + /// + /// Generates a Urlbox URL with the specified format. + /// + /// The options for generating the screenshot or PDF. + /// The format of the output, e.g., "png", "jpg", "pdf". + /// A render link URL to render the content. + public string GenerateSignedRenderLink(UrlboxOptions options) + { + return renderLinkFactory.GenerateRenderLink(urlboxConfig.BaseUrl, options, sign: true); + } + + // ** Status and Validation Methods ** + + /// + /// A method to get the status of a render from an async request + /// + /// + public async Task GetStatus(string renderId) + { + string statusUrl = $"{urlboxConfig.BaseUrl}{STATUS_ENDPOINT}/{renderId}"; + + HttpResponseMessage response = await httpClient.GetAsync(statusUrl); + if (response.IsSuccessStatusCode) + { + string responseData = await response.Content.ReadAsStringAsync(); + + JsonSerializerOptions deserializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + AsyncUrlboxResponse? asyncResponse = JsonSerializer.Deserialize(responseData, deserializerOptions); + + if (asyncResponse != null) + { + return asyncResponse; + } + } + throw new ArgumentException($"Failed to check status of async request: {GetUrlboxErrorMessage(response)}"); + } + + /// + /// Verifies a webhook response's x-urlbox-signature header to ensure it came from Urlbox. + /// Only supports a result from an Async Urlbox request + /// + /// The x-urlbox-signature header. + /// The content to verify. + /// Returns a UrlboxWebhookResponse + /// Thrown when the webhook secret is not set in the Urlbox instance. + public UrlboxWebhookResponse VerifyWebhookSignature(string header, string content) + { + if (urlboxWebhookValidator is null) + { + throw new ArgumentException("Please set your webhook secret in the Urlbox instance before calling this method."); + } + + bool isValid = urlboxWebhookValidator.VerifyWebhookSignature(header, content); + + if (!isValid) + { + throw new System.Exception("Cannot verify that this response came from Urlbox. Double check that you're webhook secret is correct."); + } + + JsonSerializerOptions deserializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + UrlboxWebhookResponse? urlboxWebhookResponse = JsonSerializer.Deserialize(content, deserializerOptions) ?? + throw new System.Exception("Cannot verify that this response came from Urlbox. Response could not be deserialized."); + + return urlboxWebhookResponse; + } + + // PRIVATE + + /// + /// Private method to avoid duplication when getting screenshot async + /// + /// + /// + /// + /// + private async Task TakeScreenshotAsyncWithTimeout(UrlboxOptions options, int timeout) + { + AsyncUrlboxResponse asyncResponse = await RenderAsync(options); + int pollingInterval = 2000; // 2 seconds + DateTime startTime = DateTime.Now; + + while ((DateTime.Now - startTime).TotalMilliseconds < timeout) + { + AsyncUrlboxResponse asyncUrlboxResponse = await GetStatus(asyncResponse.RenderId); + + if (asyncUrlboxResponse.Status == "succeeded") + { + return asyncUrlboxResponse; + } + + await Task.Delay(pollingInterval); + } + throw new TimeoutException("The screenshot request timed out."); + } + + /// + /// Gets the x-urlbox-error-message from a request + /// + /// The Error message as a string + private static string GetUrlboxErrorMessage(HttpResponseMessage response) + { + response.Headers.TryGetValues("x-urlbox-error-message", out IEnumerable? values); + + if (values != null) + { + return $"Request failed: {values.FirstOrDefault()}"; + } + return $"Request failed: No x-urlbox-error-message header found"; + } + + /// + /// Makes an HTTP POST request to the Urlbox API endpoint and returns the response as a object. + /// + /// The Urlbox API endpoint to send the request to. Must be either /render/sync or /render/async. + /// The object containing the configuration options for the API request. + /// A object containing the result of the API call, which includes the rendered URL and additional data. + /// Thrown when an invalid endpoint is provided or when the request fails with a non-successful response code. + /// + /// The method first validates the endpoint, then constructs the request with the provided options, serializing them to JSON using the snake_case naming policy. + /// The request is authenticated via a Bearer token, and the response is deserialized from camelCase to PascalCase to fit C# conventions. + /// + private async Task MakeUrlboxPostRequest(string endpoint, UrlboxOptions options) + { + if (endpoint != SYNC_ENDPOINT && endpoint != ASYNC_ENDPOINT) + { + throw new ArgumentException("Endpoint must be one of /render/sync or /render/async."); + } + if ( + options.ResponseType != null && + (options.ResponseType != ResponseType.Json || options.ResponseType != ResponseType.Jsondebug) + ) + { + throw new ArgumentException("Response type must be Json when using POST methods in this SDK."); + } + + string url = urlboxConfig.BaseUrl + endpoint; + + // UrlboxOptions uses it's own custom serialiser + string optionsAsJson = Serialize.ToJson(options); + + HttpRequestMessage request = new(HttpMethod.Post, url) + { + Content = new StringContent(optionsAsJson, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Authorization", $"Bearer {urlboxConfig.Secret}"); + JsonSerializerOptions deserializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + HttpResponseMessage response = await httpClient.SendAsync(request); + + string responseData = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return endpoint switch + { + SYNC_ENDPOINT => JsonSerializer.Deserialize(responseData, deserializerOptions) + ?? throw new System.Exception("Could not deserialize response from Urlbox API."), + ASYNC_ENDPOINT => JsonSerializer.Deserialize(responseData, deserializerOptions) + ?? throw new System.Exception("Could not deserialize response from Urlbox API."), + _ => throw new ArgumentException("Invalid endpoint."), + }; + } + else + { + throw UrlboxException.FromResponse(responseData, deserializerOptions); + } + } + + /// + /// Makes an HTTP POST request to the Urlbox API endpoint and returns the response as a object. + /// + /// The Urlbox API endpoint to send the request to. Must be either /render/sync or /render/async. + /// The object containing the configuration options for the API request. + /// A object containing the result of the API call, which includes the rendered URL and additional data. + /// Thrown when an invalid endpoint is provided or when the request fails with a non-successful response code. + /// + /// The method first validates the endpoint, then constructs the request with the provided options, serializing them to JSON using the snake_case naming policy. + /// The request is authenticated via a Bearer token, and the response is deserialized from camelCase to PascalCase to fit C# conventions. + /// + private async Task MakeUrlboxPostRequest(string endpoint, object options) + { + if (endpoint != SYNC_ENDPOINT && endpoint != ASYNC_ENDPOINT) + { + throw new ArgumentException("Endpoint must be one of /render/sync or /render/async."); + } + string url = urlboxConfig.BaseUrl + endpoint; + JsonSerializerOptions serializeOptions = new() + { + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault, + WriteIndented = true + }; + + string optionsAsJson = JsonSerializer.Serialize(options, serializeOptions); + + HttpRequestMessage request = new(HttpMethod.Post, url) + { + Content = new StringContent(optionsAsJson, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Authorization", $"Bearer {urlboxConfig.Secret}"); + JsonSerializerOptions deserializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + HttpResponseMessage response = await httpClient.SendAsync(request); + + string responseData = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return endpoint switch + { + SYNC_ENDPOINT => JsonSerializer.Deserialize(responseData, deserializerOptions) + ?? throw new System.Exception("Could not deserialize response from Urlbox API."), + ASYNC_ENDPOINT => JsonSerializer.Deserialize(responseData, deserializerOptions) + ?? throw new System.Exception("Could not deserialize response from Urlbox API."), + _ => throw new ArgumentException("Invalid endpoint."), + }; + } + else + { + + throw UrlboxException.FromResponse(responseData, deserializerOptions); + } + } + + /// + /// Downloads content from the given Urlbox render link and processes it using the provided onSuccess function. + /// + /// The render link Urlbox URL. + /// The function to execute when the download is successful. + /// The result of the success function. + private async Task Download(string urlboxUrl, Func> onSuccess) + { + HttpResponseMessage response = await httpClient.GetAsync(urlboxUrl).ConfigureAwait(false); + string? contentType = response.Content.Headers.ContentType?.ToString(); + + if (response.IsSuccessStatusCode && contentType != null) + { + if (!contentType.Contains("text/plain")) + { + throw new System.Exception("Please use Response Type of Binary when downloading a render link."); + } + return await onSuccess(response); + } + else + { + throw new System.Exception(GetUrlboxErrorMessage(response)); + } + } +} diff --git a/UrlboxSDK/UrlboxSDK.csproj b/UrlboxSDK/UrlboxSDK.csproj new file mode 100644 index 0000000..e45461f --- /dev/null +++ b/UrlboxSDK/UrlboxSDK.csproj @@ -0,0 +1,56 @@ + + + net6.0 + true + 10.0 + Urlbox.sdk.dotnet + enable + 2.0.0 + + Urlbox + Urlbox + urlbox-dotnet + Urlbox captures flawless full page automated screenshots. Get web data from the screenshot API you can depend on. + + LICENSE.txt + README.md + + https://github.com/urlbox/urlbox-dotnet + git + https://urlbox.com + + icon-128x128.png + screenshot, urlbox, automation, api, puppeteers, screenshots, playwright, url to png + Patch README.md with clarified code comments + © 2024 Urlbox + + + + + true + ./ + + + + true + ./ + + + true + + + ./README.md + + + + + + + + + <_Parameter1>UrlboxSDK.MsTest + + + + + diff --git a/UrlboxSDK/Webhook/Resource/Meta.cs b/UrlboxSDK/Webhook/Resource/Meta.cs new file mode 100644 index 0000000..a9de2a4 --- /dev/null +++ b/UrlboxSDK/Webhook/Resource/Meta.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace UrlboxSDK.Webhook.Resource; + +/// +/// Represents the Metadata that comes back from Urlbox's Webhook Response +/// +public sealed class Meta +{ + public string StartTime { get; } + public string EndTime { get; } + + [JsonConstructor] + public Meta(string startTime, string endTime) + { + StartTime = startTime; + EndTime = endTime; + } +} diff --git a/UrlboxSDK/Webhook/Resource/UrlboxWebhookResponse.cs b/UrlboxSDK/Webhook/Resource/UrlboxWebhookResponse.cs new file mode 100644 index 0000000..ef8e8e0 --- /dev/null +++ b/UrlboxSDK/Webhook/Resource/UrlboxWebhookResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using UrlboxSDK.Response.Resource; + +namespace UrlboxSDK.Webhook.Resource; + +public sealed class UrlboxWebhookResponse +{ + public string Event { get; } + public string RenderId { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorUrlboxResponse.UrlboxError? Error { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SyncUrlboxResponse? Result { get; } + public Meta Meta { get; } + + [JsonConstructor] + public UrlboxWebhookResponse( + string @event, + string renderId, + Meta meta, + SyncUrlboxResponse? result = null, + ErrorUrlboxResponse.UrlboxError? error = null + ) + { + if (result != null && error != null) + { + throw new ArgumentException("The UrlboxWebhookResponse must have one of Error or Response, not both."); + } + + Event = @event; + RenderId = renderId; + Meta = meta; + if (result != null) Result = result; + if (error != null) Error = error; + } +} diff --git a/UrlboxSDK/Webhook/Validator/UrlboxWebhookValidator.cs b/UrlboxSDK/Webhook/Validator/UrlboxWebhookValidator.cs new file mode 100644 index 0000000..8d98df9 --- /dev/null +++ b/UrlboxSDK/Webhook/Validator/UrlboxWebhookValidator.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; + +namespace UrlboxSDK.Webhook.Validator; +/// +/// A class encompassing webhook validation logic. +/// +public sealed class UrlboxWebhookValidator +{ + private string webhookSecret; + + /// + /// Constructs a UrlboxWebhookValidator + /// + /// + /// + public UrlboxWebhookValidator(string secret) + { + if (String.IsNullOrEmpty(secret)) + { + throw new ArgumentException("Unable to verify signature as Webhook Secret is not set. You can find your webhook secret inside your project\'s settings - https://www.urlbox.com/dashboard/projects"); + } + this.webhookSecret = secret; + } + + /// + /// Verifies the webhook signature from the request hash. + /// + /// The x-urlbox-signature header + /// + /// + /// Thrown when there is an empty header + public bool VerifyWebhookSignature(string header, string content) + { + if (String.IsNullOrEmpty(header) || !header.Contains("t=") || !header.Contains("sha256=") || !header.Contains(",")) + { + throw new ArgumentException("Unable to verify signature as header is empty or malformed. Please ensure you pass the `x-urlbox-signature` from the header of the webhook response."); + } + + string timestamp = GetTimestampFromHeader(header); + string signature = GetSignature(header); + + string generatedHash = GenerateHash(timestamp, content); + return generatedHash == signature; + } + + /// + /// Method to generate the HMAC hash from the urlbox header's timestamp and content. + /// + /// + /// + /// + public string GenerateHash(string headerTimestamp, string content) + { + string messageToHash = headerTimestamp + "." + content; + byte[] secretKeyBytes = Encoding.UTF8.GetBytes(this.webhookSecret); + byte[] messageBytes = Encoding.UTF8.GetBytes(messageToHash); + + using HMACSHA256 hmacsha256 = new(secretKeyBytes); + byte[] hashBytes = hmacsha256.ComputeHash(messageBytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); // Convert hash to hex string + } + + /// + /// Method to get the signature from the x-urlbox-signature header. + /// + /// + /// + /// + public string GetSignature(string header) + { + string[] commaSplit = header.Split(','); + string signatureWithPrefix = commaSplit[1]; + string signature = signatureWithPrefix.Split('=').Last(); + + if (!signatureWithPrefix.Contains("sha256=") || String.IsNullOrEmpty(signature)) + { + throw new ArgumentException("The signature could not be found, please ensure you are passing the x-urlbox-signature header."); + } + + return signature; + } + + /// + /// Gets the timestamp from the x-urlbox-signature header. + /// + /// + /// + private string GetTimestampFromHeader(string header) + { + string[] commaSplit = header.Split(','); + string timestampWithPrefix = commaSplit[0]; + string timestamp = timestampWithPrefix.Split('=').Last(); + + if (!timestampWithPrefix.Contains("t=") || String.IsNullOrEmpty(timestamp)) + { + throw new ArgumentException("The timestamp could not be found, please ensure you are passing the x-urlbox-signature header."); + } + + return timestamp; + } +} diff --git a/UrlboxSDK/icon-128x128.png b/UrlboxSDK/icon-128x128.png new file mode 100644 index 0000000..6388152 Binary files /dev/null and b/UrlboxSDK/icon-128x128.png differ diff --git a/UrlboxSDK/images/gh.png b/UrlboxSDK/images/gh.png new file mode 100644 index 0000000..b61761f Binary files /dev/null and b/UrlboxSDK/images/gh.png differ diff --git a/UrlboxSDK/images/projectKeys.png b/UrlboxSDK/images/projectKeys.png new file mode 100644 index 0000000..5c2d08f Binary files /dev/null and b/UrlboxSDK/images/projectKeys.png differ diff --git a/UrlboxSDK/images/urlbox-png.png b/UrlboxSDK/images/urlbox-png.png new file mode 100644 index 0000000..40e636a Binary files /dev/null and b/UrlboxSDK/images/urlbox-png.png differ diff --git a/UrlboxSDK/packages.lock.json b/UrlboxSDK/packages.lock.json new file mode 100644 index 0000000..c123baa --- /dev/null +++ b/UrlboxSDK/packages.lock.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "k6PWQMuoBDGGHOQTtyois2u4AwyVcIwL2LaSLlTZQm2CYcJ1pxbt6jfAnpWmzENA/wfrYRI/X9DTLoUkE4AsLw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "dzXN0+V1AyjOe2xcJ86Qbo233KHuLEY0njf/P2Kw8SfJU+d45HNS2ctJdnEnrWbM9Ye2eFgaC5Mj9otRMU6IsQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", + "Microsoft.Extensions.Primitives": "6.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "xlzi2IYREJH3/m6+lUrQlujzX8wDitm4QGnUu6kUXTQAWPuZY8i+ticFJbzfqaetLA6KR/rO6Ew/HuYD+bxifg==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "9+PnzmQFfEFNR9J2aDTfJGGupShHjOuGw4VUv+JB044biSHrnmCIMD+mJHmb2H7YryrfBEXDurxQ47gJZdCKNQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + } + } + } +} \ No newline at end of file