C# PDF Generation

C# PDF API — Template Based PDF Generation

Introduction


C# is a programming language that supports Object Oriented Programming, and it's actively preferred by many institutions. There are many libraries available in the market for PDF generation. Almost all of them are HTML based and have several drawbacks, including the need for a lot of developer's time.

Template-based PDF generation is the preferred approach for agile organizations. Business users can use existing templates as-is. EDocGen is the best API in this segment. In this article, we integrate a C# application with the REST API.

The application is developed with ASP.NET Core on .NET 10, the current long-term support (LTS) release.

Before diving into the code, here's the working logic of the application. It's a web API project. The application accepts a file in JSON format from a client, converts it to PDF, and emails the converted file to the client's email address.

The client sends a JSON file and an email address to the ASP.NET Core Web API, which authenticates against EDocGen, generates the document, and emails it.

Requirements

  1. .NET 10 SDK (LTS, supported through November 2028)
  2. Docker
  3. A template loaded on the EDocGen UI, with its template ID configured in appsettings.json

Project Setup


Create an ASP.NET Core Web API project. From the CLI:

dotnet new webapi -n PdfGeneration -f net10.0
cd PdfGeneration

Unlike the original .NET 5 version of this project, you won't need to pull in Newtonsoft.Json or RestSharp from NuGet. .NET's built-in System.Text.Json and HttpClient (via IHttpClientFactory) cover everything those packages did here, which means fewer third-party dependencies to patch and version. The one package this project does add is for Redis-backed distributed caching:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Your .csproj should look like this:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.0" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="Sources\" />
  </ItemGroup>

</Project>

<Nullable>enable</Nullable> turns on nullable reference types, so the compiler flags possible null-reference bugs before you ship them — worth turning on for any new project.

We'll use Redis as the cache mechanism, run in a Docker container. Save this as docker-compose.yml:

services:
  redis-sample:
    image: redis:latest
    container_name: redis-cache
    ports:
      - "6379:6379"
    networks:
      - base-network

networks:
  base-network:
    driver: bridge

Start it with:

docker compose up -d

Project Namespaces and Classes

The project structure is:

PdfGeneration.csproj
Program.cs
appsettings.json
docker-compose.yml
Controllers/
  DocumentController.cs
Services/
  EDocGenApiClient.cs
  AuthenticationService.cs
  LoginService.cs
  CacheService.cs
  DocumentService.cs
  EmailService.cs
  FileUploadService.cs
Models/
  Dtos.cs
  Options.cs
Constants/
  Constants.cs
Exceptions/
  CustomExceptions.cs

DocumentController: Handles the client's POST request with the JSON file and email.

EDocGenApiClient: A typed HttpClient wrapper for every call to the EDocGen REST API — login, document generation, output lookup, and email. This replaces the RestClient/RestRequest calls from the RestSharp-based version.

AuthenticationService: Checks whether a token is already cached.

LoginService: Gets a token using username and password.

CacheService: Wraps IDistributedCache for get/set operations against Redis.

DocumentService: Orchestrates validation, upload, document generation, and handoff to the email step.

EmailService: Waits for generation to finish, then requests the output be emailed to the client.

FileUploadService: Saves the uploaded file to the /Sources folder.

Composition root: Program.cs

The old project split registration between Program.cs and Startup.cs. That split was removed starting with .NET 6, and everything — including dependency injection — now lives in one place:

using PdfGeneration.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

builder.Services.Configure<EDocGenOptions>(builder.Configuration.GetSection("EDocGen"));
builder.Services.Configure<UserInfoOptions>(builder.Configuration.GetSection("UserInfo"));

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
    options.InstanceName = "PdfGenerator:";
});

builder.Services.AddHttpClient<EDocGenApiClient>(client =>
{
    var baseUrl = builder.Configuration["EDocGen:BaseUrl"] ?? "https://app.edocgen.com";
    client.BaseAddress = new Uri(baseUrl);
});

builder.Services.AddScoped<CacheService>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<AuthenticationService>();
builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<FileUploadService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

Registering EDocGenApiClient with AddHttpClient<T> is the important part here: it hands out HttpClient instances from a managed pool instead of new-ing one up per request, which protects the app from socket exhaustion under load.

appsettings.json now holds the EDocGen and Redis settings:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Redis": "localhost:6379"
  },
  "EDocGen": {
    "BaseUrl": "https://app.edocgen.com",
    "DefaultTemplateId": "REPLACE_WITH_YOUR_TEMPLATE_ID"
  },
  "UserInfo": {
    "Username": "REPLACE_WITH_USERNAME",
    "Password": "REPLACE_WITH_PASSWORD"
  }
}

Don't commit real credentials to source control — use dotnet user-secrets locally and environment variables or a secrets manager in production:

dotnet user-secrets set "UserInfo:Username" "you@example.com"
dotnet user-secrets set "UserInfo:Password" "your-password"
dotnet user-secrets set "EDocGen:DefaultTemplateId" "your-template-id"

Authentication and Login

This is still the first step of the flow. AuthenticationService runs at the start of every request. It checks Redis for a cached token; if one exists, generation continues immediately. If not, LoginService fetches a fresh token, which is cached with a 3-hour TTL.

AuthenticationService

using PdfGeneration.Constants;

namespace PdfGeneration.Services;

public sealed class AuthenticationService(CacheService cacheService, LoginService loginService)
{
    public async Task SetCredentialAsync(CancellationToken ct = default)
    {
        if (await IsNotTokenCachedAsync(ct))
            await SetTokenToCacheAsync(ct);
    }

    private async Task<bool> IsNotTokenCachedAsync(CancellationToken ct)
    {
        var result = await cacheService.GetAsync(RedisConstant.PdfGeneratorToken, ct);
        return string.IsNullOrEmpty(result);
    }

    private async Task SetTokenToCacheAsync(CancellationToken ct)
    {
        var token = await loginService.LoginAccountAsync(ct);
        await cacheService.SetAsync(RedisConstant.PdfGeneratorToken, token, TimeSpan.FromHours(3), ct);
    }
}

The constructor AuthenticationService(CacheService cacheService, LoginService loginService) is a primary constructor — a C# 12+ shorthand that removes boilerplate field declarations.

LoginService

using Microsoft.Extensions.Options;
using PdfGeneration.Exceptions;
using PdfGeneration.Models;

namespace PdfGeneration.Services;

public sealed class LoginService(EDocGenApiClient apiClient, IOptions<UserInfoOptions> userInfo, ILogger<LoginService> logger)
{
    public async Task<string> LoginAccountAsync(CancellationToken ct = default)
    {
        var username = userInfo.Value.Username;
        var password = userInfo.Value.Password;
        try
        {
            var response = await apiClient.LoginAsync(username, password, ct);
            return response.Token;
        }
        catch (Exception ex) when (ex is not LoginException)
        {
            logger.LogError(ex, "Error occurred while getting token with username {Username}", username);
            throw new LoginException($"Error occurred while getting token with username: {username}");
        }
    }
}

CacheService

using Microsoft.Extensions.Caching.Distributed;

namespace PdfGeneration.Services;

public sealed class CacheService(IDistributedCache cache)
{
    public async Task<string?> GetAsync(string key, CancellationToken ct = default) =>
        await cache.GetStringAsync(key, ct);

    public async Task SetAsync(string key, string value, TimeSpan ttl, CancellationToken ct = default) =>
        await cache.SetStringAsync(key, value,
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }, ct);
}

Generate Document

This is the next step after authentication. We validate the uploaded JSON file, save it to /Sources, and hand it off for conversion.

URL https://app.edocgen.com/api/v1/document/generate/bulk
Method HTTP POST
Parameters documentId (string, formData): id of the template. format (string, formData): output format, docx or pdf — default docx. outputFileName (string, formData): file name for the output file. inputFile (file, formData): file containing marker values. Supports JSON, XLSX, and XML.
Headers x-access-token (string, header): authorization header obtained from login. Content-Type (string, header): multipart/form-data

This endpoint hasn't changed — only the client calling it has. Here's the part of EDocGenApiClient responsible for it:

EDocGenApiClient

using System.Net.Http.Headers;
using System.Net.Http.Json;
using PdfGeneration.Constants;
using PdfGeneration.Exceptions;
using PdfGeneration.Models;

namespace PdfGeneration.Services;

public sealed class EDocGenApiClient(HttpClient httpClient)
{
    public async Task<LoginResponse> LoginAsync(string username, string password, CancellationToken ct)
    {
        using var response = await httpClient.PostAsJsonAsync("/login", new { username, password }, ct);
        await EnsureSuccessAsync(response, "Error occurred while getting token");
        return await response.Content.ReadFromJsonAsync<LoginResponse>(ct)
            ?? throw new LoginException("Login response could not be parsed");
    }

    public async Task GenerateDocumentAsync(FileRequestModel request, string templateId, CancellationToken ct)
    {
        using var content = new MultipartFormDataContent();
        await using var fileStream = File.OpenRead(request.FilePath);
        using var fileContent = new StreamContent(fileStream);
        fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
        content.Add(fileContent, "inputFile", Path.GetFileName(request.FilePath));
        content.Add(new StringContent(templateId), "documentId");
        content.Add(new StringContent(request.FileName), "outputFileName");
        content.Add(new StringContent(FileGenerationConstants.PdfOutputFormat), "format");
        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/document/generate/bulk") { Content = content };
        httpRequest.Headers.Add("x-access-token", request.Token);
        using var response = await httpClient.SendAsync(httpRequest, ct);
        await EnsureSuccessAsync(response, "Error occurred while generating document");
    }

    public async Task<OutputModelDto> GetOutputByNameAsync(string outputFileName, string token, CancellationToken ct)
    {
        using var httpRequest = new HttpRequestMessage(HttpMethod.Get,
            $"/api/v1/output/name/{outputFileName}.{FileGenerationConstants.PdfOutputFormat}.{FileGenerationConstants.ZipOutputFormat}");
        httpRequest.Headers.Add("x-access-token", token);
        using var response = await httpClient.SendAsync(httpRequest, ct);
        await EnsureSuccessAsync(response, "Error occurred while fetching output");
        return await response.Content.ReadFromJsonAsync<OutputModelDto>(ct)
            ?? throw new InvalidOutputResponseException("Output model could not be parsed");
    }

    public async Task EmailOutputAsync(string outputId, string email, string token, CancellationToken ct)
    {
        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/output/email")
            { Content = JsonContent.Create(new { outId = outputId, emailId = email }) };
        httpRequest.Headers.Add("x-access-token", token);
        using var response = await httpClient.SendAsync(httpRequest, ct);
        await EnsureSuccessAsync(response, "Error occurred while emailing output");
    }

    private static async Task EnsureSuccessAsync(HttpResponseMessage response, string errorPrefix)
    {
        if (response.IsSuccessStatusCode) return;
        var body = await response.Content.ReadAsStringAsync();
        throw new InvalidServiceResponseException(
            $"{errorPrefix}: {(int)response.StatusCode} {response.ReasonPhrase} - {body}");
    }
}

FileUploadService

using PdfGeneration.Models;

namespace PdfGeneration.Services;

public static class FileUpload
{
    public static void ValidateFile(FileUploadDto fileUploadDto)
    {
        if (fileUploadDto.File.Length == 0)
            throw new ArgumentException("Uploaded file is empty");
    }
}

public sealed class FileUploadService(IWebHostEnvironment environment)
{
    private readonly string _sourcesFolder = Path.Combine(environment.ContentRootPath, "Sources");

    public async Task<string> SaveFileToFolderAsync(FileUploadDto fileUploadDto, CancellationToken ct = default)
    {
        Directory.CreateDirectory(_sourcesFolder);
        var filePath = Path.Combine(_sourcesFolder, fileUploadDto.File.FileName);
        await using var stream = new FileStream(filePath, FileMode.Create);
        await fileUploadDto.File.CopyToAsync(stream, ct);
        return filePath;
    }
}

DocumentService

using Microsoft.Extensions.Options;
using PdfGeneration.Constants;
using PdfGeneration.Exceptions;
using PdfGeneration.Models;

namespace PdfGeneration.Services;

public sealed class DocumentService(
    CacheService cacheService, EmailService emailService, FileUploadService fileUploadService,
    EDocGenApiClient apiClient, IOptions<EDocGenOptions> options, ILogger<DocumentService> logger)
{
    public async Task ExecuteDocumentProcessAsync(FileUploadDto fileUploadDto, string email, CancellationToken ct = default)
    {
        try
        {
            FileUpload.ValidateFile(fileUploadDto);
            var filePath = await fileUploadService.SaveFileToFolderAsync(fileUploadDto, ct);
            var fileRequestModel = await CreateFileRequestModelAsync(fileUploadDto, filePath, ct);
            await apiClient.GenerateDocumentAsync(fileRequestModel, options.Value.DefaultTemplateId, ct);
            await emailService.ProcessOutputAsync(fileRequestModel, email, ct);
        }
        catch (Exception ex) when (ex is not FileGenerationException)
        {
            logger.LogError(ex, "Error occurred while generating document");
            throw new FileGenerationException("Error occurred while generating document");
        }
    }

    private async Task<FileRequestModel> CreateFileRequestModelAsync(FileUploadDto fileUploadDto, string filePath, CancellationToken ct)
    {
        var token = await cacheService.GetAsync(RedisConstant.PdfGeneratorToken, ct)
            ?? throw new InvalidServiceResponseException("No cached access token was found");
        var fileName = $"{fileUploadDto.File.FileName}_{DateTime.UtcNow:yyyyMMddHHmmss}";
        return new FileRequestModel { Token = token, FilePath = filePath, FileName = fileName };
    }
}

Output Process

After the JSON is submitted for conversion, EmailService waits for generation to finish, then requests EDocGen email the finished PDF to the client. The original article used a plain 10-second Thread.Sleep here — that's now an await Task.Delay, which doesn't tie up a thread-pool thread for the whole wait.

First, the output ID is looked up:

URL https://app.edocgen.com/api/v1/output/name/{output_file_name}
Method HTTP GET
Headers x-access-token (string, header): authorization header. Content-Type (string, header): multipart/form-data

Then the email request:

URL https://app.edocgen.com/api/v1/output/email
Method HTTP POST
Body { "outId": "string", "emailId": "string" }
Headers x-access-token (string, header): authorization header. Content-Type (string, header): multipart/form-data

EmailService

using PdfGeneration.Exceptions;
using PdfGeneration.Models;

namespace PdfGeneration.Services;

public sealed class EmailService(EDocGenApiClient apiClient)
{
    public async Task ProcessOutputAsync(FileRequestModel fileRequestModel, string email, CancellationToken ct = default)
    {
        try
        {
            var outputId = await GetOutputIdAsync(fileRequestModel, ct);
            await apiClient.EmailOutputAsync(outputId, email, fileRequestModel.Token, ct);
        }
        catch (Exception ex) when (ex is not ProcessOutputException)
        {
            throw new ProcessOutputException(ex.Message);
        }
    }

    private async Task<string> GetOutputIdAsync(FileRequestModel fileRequestModel, CancellationToken ct)
    {
        // Non-blocking wait; replaces original Thread.Sleep(10000)
        await Task.Delay(TimeSpan.FromSeconds(10), ct);
        var outputModelDto = await apiClient.GetOutputByNameAsync(fileRequestModel.FileName, fileRequestModel.Token, ct);
        ValidateOutputResponse(outputModelDto);
        return outputModelDto.Output[0].Id;
    }

    private static void ValidateOutputResponse(OutputModelDto outputModelDto)
    {
        if (outputModelDto.Output.Count == 0)
            throw new InvalidOutputResponseException("Output model is not valid");
    }
}

Controller Step

This is where the JSON file and email address sent by the client are handled. It kicks off authentication, then document generation:

DocumentController

using Microsoft.AspNetCore.Mvc;
using PdfGeneration.Constants;
using PdfGeneration.Exceptions;
using PdfGeneration.Models;
using PdfGeneration.Services;

namespace PdfGeneration.Controllers;

[Route("api/[controller]/generate")]
[ApiController]
public sealed class DocumentController(AuthenticationService authenticationService, DocumentService documentService) : ControllerBase
{
    [HttpPost]
    [Route("{email}")]
    public async Task<ActionResult<ResponseModel>> GenerateDocument(
        [FromForm] FileUploadDto fileUploadDto, string email, CancellationToken ct)
    {
        try
        {
            await authenticationService.SetCredentialAsync(ct);
            await documentService.ExecuteDocumentProcessAsync(fileUploadDto, email, ct);
            return Ok(new ResponseModel
            {
                IsSuccess = true,
                StatusCode = StatusCodes.Status200OK,
                Message = $"The file named {fileUploadDto.File.FileName} converted to {FileGenerationConstants.PdfOutputFormat} format successfully. Sent to {email}."
            });
        }
        catch (FileGenerationException ex)  { return Problem(detail: ex.Message, statusCode: StatusCodes.Status502BadGateway); }
        catch (ProcessOutputException ex)   { return Problem(detail: ex.Message, statusCode: StatusCodes.Status502BadGateway); }
        catch (LoginException ex)           { return Problem(detail: ex.Message, statusCode: StatusCodes.Status401Unauthorized); }
    }
}

Models/Dtos.cs

using System.Text.Json.Serialization;

namespace PdfGeneration.Models;

public sealed class FileUploadDto           { public required IFormFile File { get; init; } }
public sealed record FileRequestModel       { public required string Token { get; init; }  public required string FilePath { get; init; }  public required string FileName { get; init; } }
public sealed record ResponseModel          { public bool IsSuccess { get; init; }  public int StatusCode { get; init; }  public required string Message { get; init; } }
public sealed record LoginResponse([property: JsonPropertyName("token")] string Token);
public sealed record OutputItem([property: JsonPropertyName("_id")] string Id);
public sealed record OutputModelDto([property: JsonPropertyName("output")] IReadOnlyList<OutputItem> Output);

Constants/Constants.cs

namespace PdfGeneration.Constants;

public static class RedisConstant          { public const string PdfGeneratorToken = "pdf-generator-token"; }
public static class FileGenerationConstants { public const string PdfOutputFormat = "pdf";  public const string ZipOutputFormat = "zip"; }

Exceptions/CustomExceptions.cs

namespace PdfGeneration.Exceptions;

public sealed class LoginException(string message)                  : Exception(message);
public sealed class InvalidServiceResponseException(string message)  : Exception(message);
public sealed class FileGenerationException(string message)          : Exception(message);
public sealed class ProcessOutputException(string message)          : Exception(message);
public sealed class InvalidOutputResponseException(string message)  : Exception(message);

Test

With the application built, it's time to test it — we'll use Postman. Run the app:

dotnet run

Then send a POST request to api/document/generate/{email} with this JSON file attached as the File field of a multipart form:

JSON_Data.json

[
  {
    "Invoice_Number": "SBU-2053501",
    "Invoice_Date": "31-07-2020",
    "Terms_Payment": "Net 15",
    "Company_Name": "Company A",
    "Billing_Contact": "A-Contact1",
    "Address": "New York, United States",
    "ITH": [{ "Heading1": "Item Description", "Heading2": "Amount" }],
    "IT":  [{ "Item_Description": "Product Fees: X", "Amount": "5,000" }]
  }
]

A successful call returns a 200 with a JSON body confirming the file name, output format, and destination email — and shortly after, the converted PDF lands in that inbox.

Wrap-up

When you adopt template-based PDF generation, you’re not just improving processes today; you’re preparing for tomorrow. Being scalable means high-volume operations, new integrations, and evolving workflows won’t hold you back.

Popular Posts