Dodawanie postów na WordPress z .NET przez REST API

Jak z aplikacji .NET zbudować payload i strzelić POST-em do /wp-json/wp/v2/posts z Bearer tokenem w nagłówku, żeby WordPress opublikował wpis w imieniu użytkownika.

JAJarosław Armatys 9 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

To ósmy odcinek serii, w której budujemy WP Importera — aplikację konsolową w .NET 6, która ściąga firmy z bazy, dogaduje się z Google Places API i publikuje gotowe ogłoszenia na WordPressie. Mamy już konfigurację bazy, dependency injection, moduł Google oraz mechanizm, który pobiera z WordPressa token JWT. Czas skleić to wszystko w całość i faktycznie wrzucić pierwszy wpis przez REST API, autoryzując się pobranym wcześniej Bearer tokenem.

Plan — serwis do publikacji

Zanim napiszesz jakąkolwiek linijkę, warto narysować sobie w głowie mały przepływ. Z bazy danych ciągniesz firmę. Na podstawie jej nazwy pytasz Google Places o szczegóły — dostajesz nazwę wizytówki, stronę internetową, adres. Następnie sklejasz z tego payload, który pasuje do kontraktu endpointu POST /wp-json/wp/v2/posts. Na końcu dokładasz nagłówek Authorization: Bearer <token> i strzelasz w WordPressa. W odpowiedzi dostajesz JSON z utworzonym zasobem, a z niego najczęściej interesuje Cię tylko jedno pole — id nowego wpisu.

Dlaczego to wszystko w osobnym serwisie, a nie w Program.cs? Bo Single Responsibility. Klasa PostPublisher ma robić jedną rzecz — wysłać wpis. Token i logowanie do WP to osobna odpowiedzialność — siedzi w IWordpressAuthService, który zrobiliśmy w poprzednim odcinku. PostPublisher tylko woła GetTokenAsync() i dostaje gotowego stringa.

Nie mieszaj autoryzacji z wysyłką treści. Jak zaczniesz w jednej klasie i pobierać token, i budować payload, i parsować response, to za trzy tygodnie nie będziesz wiedział, co gdzie zepsuć, kiedy WordPress zmieni wersję API.

Model payloadu

Najpierw prosta klasa DTO. WordPress w /wp/v2/posts oczekuje JSON-a z co najmniej trzema polami — title, content, status. Opcjonalnie doklejasz categories, tags, meta, excerpt czy featured_media. Na tym etapie zależy nam na publikacji minimalnego rekordu.

public class WpPostRequest
{
    public string Title { get; set; } = default!;
    public string Content { get; set; } = default!;
    public string Status { get; set; } = "publish";
    public int[]? Categories { get; set; }
}

Status publish u mnie jest hardcodowany — tak wynika z założenia biznesowego, bo importer dodaje od razu gotowe wpisy. U Ciebie może to być draft, jeśli wolisz jeszcze ręcznie przejrzeć efekt przed publikacją. Wystarczy podmienić default albo wystawić setter i ustawiać dynamicznie.

Teraz model odpowiedzi. Nie potrzebujemy odwzorowywać całego ciała, które zwraca WordPress — a zwraca go sporo, łącznie z URL-em, slugiem, datami, strukturą _links i całym polem content.rendered. Nas interesuje tylko id, więc robimy mały klasowy plaster:

public class WpPostResponse
{
    public int Id { get; set; }
}

Serializer zignoruje resztę pól — o to właśnie chodzi. Dostaniesz ID, będziesz mógł zapisać je z powrotem do swojej bazy (kolumna wordpress_id) i przy kolejnym imporcie odfiltrować firmy, które już mają swój wpis.

Serwis PostPublisher

Samo wysłanie requestu w .NET 6 jest banalne — HttpClient, JsonContent.Create, SendAsync, ReadFromJsonAsync. Trudniejszy jest moment, w którym to wszystko składasz w jedną klasę i pamiętasz o nagłówku autoryzacyjnym.

public class PostPublisher : IPostPublisher
{
    private readonly HttpClient _http;
    private readonly IWordpressAuthService _auth;

    public PostPublisher(HttpClient http, IWordpressAuthService auth)
    {
        _http = http;
        _auth = auth;
    }

    public async Task<int> PublishAsync(WpPostRequest post)
    {
        var token = await _auth.GetTokenAsync();
        var request = new HttpRequestMessage(HttpMethod.Post, "wp-json/wp/v2/posts")
        {
            Content = JsonContent.Create(post),
        };
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await _http.SendAsync(request);
        response.EnsureSuccessStatusCode();

        var created = await response.Content.ReadFromJsonAsync<WpPostResponse>();
        return created!.Id;
    }
}

Kilka szczegółów, które łatwo przegapić. HttpClient wstrzykujemy przez konstruktor — skonfigurowany wcześniej przez AddHttpClient<IPostPublisher, PostPublisher>() w Program.cs, z ustawionym BaseAddress wskazującym na Twoją instalację WordPressa (u mnie to https://fotowoltaika.pl/, u Ciebie będzie coś innego). Dzięki temu w HttpRequestMessage podaję ścieżkę relatywną — wp-json/wp/v2/posts — a runtime sam dokleja resztę.

JsonContent.Create(post) serializuje obiekt do JSON-a i od razu ustawia Content-Type: application/json w request body. Nie musisz tego robić ręcznie przez StringContent i MediaTypeHeaderValue — jedna linia robi dobrze.

EnsureSuccessStatusCode to moja wygoda na etapie kursu. W produkcyjnym kodzie warto zrobić to ładniej — sprawdzić konkretne kody (201 Created, 401 Unauthorized, 400 Bad Request), zalogować body błędu, ewentualnie spróbować odświeżyć token, jeśli wygasł. Na razie jednak rzucamy wyjątkiem przy czymkolwiek innym niż 2xx i idziemy dalej.

Authorization header z tokenem

Tu siedzi cały sens tego odcinka. Bez nagłówka Authorization WordPress odpowie 401 i nie opublikuje niczego. Z tokenem wygląda to tak:

request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

AuthenticationHeaderValue to klasa z System.Net.Http.Headers. Pierwszy argument — schemat (Bearer), drugi — wartość. Zero ręcznego doklejania słowa „Bearer " przed tokenem, zero kombinowania z request.Headers.Add("Authorization", ...). Framework robi to za Ciebie.

Po wykonaniu request WordPress zobaczy, że zapytanie jest wykonane w imieniu konkretnego użytkownika — tego, dla którego pobraliśmy JWT w odcinku numer 7. Wszystkie operacje POST, PUT, DELETE pójdą z jego uprawnieniami. Jeśli user nie ma roli author/editor/administrator, WordPress odrzuci wpis z 403 Forbidden. Sprawdź to, zanim zaczniesz debugować godzinami.

Token JWT w nagłówku to standard. Jeżeli dzisiaj zrozumiesz schemat, zrozumiesz też integrację z dziesiątkami innych API — GitHub, Stripe, SendGrid, większość SaaS-ów działa tak samo.

Warto też wiedzieć, że HttpRequestMessage tworzysz na każde wywołanie osobno. Nie współdziel go między requestami — to obiekt jednorazowy. HttpClient odwrotnie: rób z niego singleton (albo typed client przez IHttpClientFactory), bo w przeciwnym wypadku w sandboxach produkcyjnych wyczerpiesz socket-y.

Składanie payloadu

Na koniec krótko o tym, jak sam WpPostRequest powstaje w Program.cs. Ciągniemy firmę z bazy, pytamy Google o PlaceDetails dla nazwy, a potem składamy obiekt:

var details = await _googlePlaces.GetDetailsAsync(listing.Name);

var post = new WpPostRequest
{
    Title = details.Result.Name,
    Content = $"Strona internetowa: {details.Result.Website}. ID w bazie: {listing.Id}.",
    Categories = new[] { 5 } // ID kategorii w WP
};

var wpId = await _publisher.PublishAsync(post);

Zwróć uwagę na interpolację stringów z prefiksem $"..." — to najczystszy sposób budowania dynamicznych opisów, bez konkatenacji ze znakami +. W odcinku pokazuję, że podmieniam w opisie ID z bazy ({listing.Id}), żeby po publikacji móc w panelu WordPressa zweryfikować, czy faktycznie leci właściwy rekord. W produkcji zrobisz tu już prawdziwy opis firmy — z mapką, godzinami otwarcia, formularzem kontaktowym. Na razie chodzi o to, żeby dowieźć proof of concept.

Po uruchomieniu aplikacji pod debuggerem widzę w konsoli, że WP odpowiedział 201 Created, a z ciała wyciągam Id = 496. Loguję się do panelu WordPressa, wchodzę w Ogłoszenia — nowy wpis jest opublikowany, w treści widać stronę firmy i numer ID z bazy. Dokładnie o to chodziło.

Co dalej?

W kolejnych odcinkach rozbudujemy payload o resztę rzeczy, które normalnie idą na wizytówce — mapka z koordynatami, godziny otwarcia, kategorie niestandardowe, obraz wyróżniający. WordPress wystawia to przez meta oraz featured_media, więc zabawa będzie głównie w dobre zmapowanie pól. Wrzucimy też retry na poziomie HTTP (Polly) i logowanie body błędu, żeby 500-ki nie gubiły się bez śladu.

Jeżeli chcesz przejrzeć cały kod z odcinka — leży na github.com/jarmatys/WPImporter. Ściągnij, porównaj ze swoją wersją, sklonuj branch z tego odcinka. A jeśli masz pytania albo coś Ci nie zagrało — wal śmiało w komentarz pod filmem. Idziemy dalej.

Tagi:#course-dotnet-wordpress#wordpress#api#httpclient#csharp

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.