ResponseEntity i obsługa błędów

ResponseEntity i obsługa błędów
krancki
  • Rejestracja:prawie 7 lat
  • Ostatnio:ponad 2 lata
  • Lokalizacja:74.7261832, -41.7409518
  • Postów:151
0

Hej
chciałbym się podzielić swoimi przemyśleniami i popytać was o opinie, jak wy to widzicie.
Ostatnio siadłem do tematu controllerów u nas w aplikacji, zastanawiałem się w jaki sposób ładnie obsługiwać błędy.
Projekt został tworzony od zera więc przepchaliśmy Eithera przez wszystkie warstwy aby zapewnić dobrą obsługę błedów.
Na warstwie controllerów dostajemy eithera, jeżeli dostajemy Issue rzucamy wyjątek które jest łapany w ControllerAdvice. Trochę mi się to nie podoba, nie przepadam nad rzucaniem wyjątków i łapaniem go gdzieś wyżej np ApiErrorHandler. Coraz większa ilość @ExceptionHandler zachęciła nas do zmiany obsługi błedów więc postanowiliśmy stworzyć klase pomocniczą :

Kopiuj
class SuccessOrError <T, K extends ErrorResponse>>

przykładowa metoda kontrolera

Kopiuj
 public ResponseEntity<SuccessOrError<JakaśDataDto, ErrorDto>>

Serializacja też ogarnięta. Fabryka dla ErrorDto przyjmuje w parametrze Issue<?> który pozwala na zbudowanie odpowiedniej odpowiedzi.
Fabryka również ma dostarczoną listę typów Issuesów z których w razie wystąpienia ma wyciągnąć message i zwrócić użytkownikowi.
Np chcemy powiedzieć użytkownikowi że:

Kopiuj
{
"type": "CREATE_TEMPLATE_ERROR",
"message": "Nie udało się utworzyć szablonu."
"Caused by": [
 "Boś głupi",
 "Zła wartość dla pola: mahmol",
 "Nierozpoznany atrybut: bbebrre"
]
}

I jeżeli wszystko dobrze to :

Kopiuj
{
"template":[...]
}

Sam issue pozwala na wyszukanie root messegów dla zdefiniowanego typu

Kopiuj
public interface Issue<T extends Enum<?>>
{
    String getMessage();

    T getIssueType();

    Optional<Exception> getRootCause();

    Set<? extends Issue<?>> getRootIssue();

 default Set<Issue<?>> findRootIssuesByTypes(Set<Enum<?>> issueTypes)

Ogólnie taki sposób mi się podoba, bo Issuesy w core rzucają błąd, nic ich nie obchodzi. Każda warstwa wyżej tworzy swój Issue z informacją czego nie udało się zrobić w jej warstwie wraz z informacją z dołu. Powstaje wtedy graf przeważnie z pojedynczymi wiązaniami (Wiele wiązań w przypadku gdy wiemy że użytkownik podał kilka błędnych atrybutów, zwracamy wtedy informacje o wszystkich złych):

Kopiuj
- Nie udało się edytować szablonu *1                 (API)
  -> Nie można wczytać szablonu                       (CORE) 
    -> Wystąpił błąd ładowania atrybutów *2             (CORE)
      -> Nie można odczytać atrybutu dla cośtamcośtam        (CORE)
        -> Błąd odczytania pliku: ścieżka, Plik nie istnieje   (INFRA)

Wtedy na warstwie api możemy decydować którą informację możemy wyświetlić użytkownikowi np *1 i *2 , jednocześnie logująć cały stack i informacje krok po kroku co się wydarzyło.
Co o tym myślicie ? Jakie znacie fajne sposoby obsługi błędów ?

edytowany 5x, ostatnio: krancki
Charles_Ray
  • Rejestracja:około 17 lat
  • Ostatnio:dzień
  • Postów:1874
1

Na warstwie controllerów dostajemy eithera, jeżeli dostajemy Issue rzucamy wyjątek które jest łapany w ControllerAdvice.

To trochę tak jakbyś biegł maraton i na ostatniej prostej się przewrócił ;) Jak już się bawicie w Eithery, to obsługę możecie zrobić w pełni manualnie.

W sumie skoro macie serializację własnej klasy, to dlaczego nie napisaliście serializacji dla Eithera? Może już nawet są gotowe adaptery Vavr-Spring MVC. Jaka jest przewaga własnego typu?

Z punktu widzenia klienta (np. UI) chciałbyś wypluć strukturę danych z jakimś userMessage (przemyśleliście i18n?) oraz errorCode, żeby pozapinać warunki. Nie wiem czy takie zbieranie po drodze skutków błędu ma sens - co miałby zrobić UI z takim grafem? Po drugie pewnie w 99% przypadków ludzie i tak będą przerzucać błąd bez dodawania pośredniego kontekstu, wiec zamiast grafu będzie pojedyncza wartość.

To na co bym zwrócił uwagę:

  1. Error cody
  2. i18n
  3. Przemyśleć obsługę od strony klienta
  4. Łatwość użycia z perspektywy usługi - idealnie jakby można było zwrócić z kontrolera po prostu Either. Przykładowo, pusty Optional już jest mapowany przez Springa na 404 - coś w ten deseń

”Engineering is easy. People are hard.” Bill Coughran
edytowany 5x, ostatnio: Charles_Ray
Riddle
Administrator
  • Rejestracja:prawie 15 lat
  • Ostatnio:około 2 godziny
  • Lokalizacja:Laska, z Polski
  • Postów:10067
1

Chyba masz na myśli obsługę wyjątków?

Bo możesz zwrócić błąd klientowi, nawet jak nie poleciał żaden wyjątek; i możesz też nie zwrócić błędu nawet jesli poleciał.

krancki
  • Rejestracja:prawie 7 lat
  • Ostatnio:ponad 2 lata
  • Lokalizacja:74.7261832, -41.7409518
  • Postów:151
0

@TomRiddle: Nieee, aktualnie zawsze rzucamy wyjątek w kontrolerze. Dlatego chcemy zastąpić rzucanie wyjątków, zwykłym budowaniem odpowiedzi

edytowany 1x, ostatnio: krancki
S9
  • Rejestracja:ponad 4 lata
  • Ostatnio:około 2 lata
  • Lokalizacja:Warszawa
  • Postów:1092
1

Jak masz Either to możesz przecież zrobić mapLeft i mapRight to ResponseEntity<?>


Shalom
  • Rejestracja:około 21 lat
  • Ostatnio:prawie 3 lata
  • Lokalizacja:Space: the final frontier
  • Postów:26433
0

Ja chyba nie bardzo rozumiem ten pomysł z rzucaniem wyjątku na koniec. Czemu nie jakieś:

Kopiuj
    public static <T, E extends SomeDomainError> ResponseEntity<?> convert(Either<E, T> result) {
        return result.fold(
             e -> ResponseEntity.status(e.getErrorCode()).body(new SomeErrorResponse(e.getMessage())),
             body -> ResponseEntity.ok(body)
        );
    }

"Nie brookliński most, ale przemienić w jasny, nowy dzień najsmutniejszą noc - to jest dopiero coś!"
edytowany 1x, ostatnio: Shalom
krancki
  • Rejestracja:prawie 7 lat
  • Ostatnio:ponad 2 lata
  • Lokalizacja:74.7261832, -41.7409518
  • Postów:151
0

@Shalom: Rzucanie wyjątku wymyślone było na samym początku, dlatego chcemy to zmienić. Ten przykład co dałeś jest spoko. Tylko dochodzi jeszcze ustalenie odpowiedniego errorCodu i messega który nie jest tylko przekazywany z najwyższej warstwy tzn Controller z endpointem Post /template nie może utworzyć szablonu, no właśnie z jakiego powodu ? Dostaje Issue z warstwy niżej, więc tworzy swoje issue z message: "Uhu uhu nie mogę utworzyć szablony" typ:"CREATE_TEMPLATE_ERROR" rootIssue:"Issue z niższej warstwy" , w tej sytuacji aby wydedukować co się stało i jaki error code zwrócić, musimy przejrzeć cały stack issuesa(Interface wyżej). Przydał by się jakiś resolver strategii, i każda strategia sprawdza czy rozpoznaje jakieś issuesy w grafie i jeżeli rozpoznaje to zajmie się tworzeniem ErrorDto z odpowiednim statusem i messagem.
Przykłady:

Kopiuj
Strategia dla  errorCode 404  (* zwracamy message na front)

- Nie udało się stworzyć szablonu *                (API)
  -> Wystąpił błąd ładowania atrybutów              (CORE)
    -> Nie można odczytać atrybutu dla cośtamcośtam  *      (CORE)
      -> Błąd odczytania pliku: ścieżka, Plik nie istnieje   (INFRA)

Strategia dla errorCode 400 (* zwracamy message na front)

- Nie udało się stworzyć szablonu *                (API)
  -> Wystąpił błąd ładowania atrybutów              (CORE)
    -> Błąd rozpoznania atrybutu nr1 *      (CORE)

Strategia dla errorCode 422 (* zwracamy message na front)

- Nie udało się stworzyć szablonu *                (API)
  -> Wystąpił błąd ładowania atrybutów              (CORE)
    -> Dla takiego szablonu atrybut jest niedostępny *      (CORE)
edytowany 2x, ostatnio: krancki
krancki
  • Rejestracja:prawie 7 lat
  • Ostatnio:ponad 2 lata
  • Lokalizacja:74.7261832, -41.7409518
  • Postów:151
0

Wygodniej gdzieś w bebechach rzucić wyjątek NotFoundException, złapać go w controllerAdvice i zwrócić 404 :D

edytowany 1x, ostatnio: krancki
Riddle
No tak. I co w tym złego? Bo nie rozumiem.
krancki
Wyjątki są złe bo są wolne. Api wie że dostanie eithera i może dostać issue.
Riddle
Wada runtime'u ;D
Shalom
  • Rejestracja:około 21 lat
  • Ostatnio:prawie 3 lata
  • Lokalizacja:Space: the final frontier
  • Postów:26433
0

Przyznam ze nie bardzo rozumiem po co chcesz takie dziwne rzeczy robić. Error jest istotny dla frontu i tyle. Nie wiem co ty chcesz robić w tej swojej strategii za bardzo. Jak dla mnie to mylisz poziom abstrakcji i kwestię logowania błędów z wyświetlaniem userowi odpowiedniej informacji. Usera w ogóle nie obchodzi ze error jest dlatego ze query do bazy sie popsuło. To kod domenowy powinien sterować tym co leci jako Left, a nie że budujesz jakiś wielki graf a potem jakaś złożona logika robi analizę.

Więc po prostu gdzieśtam w kodzie jest jakieś peekLeft() które loguje error warstwy niżej a następnie mapLeft() które zamienia jakiś niskopoziomowy error na error domenowy który dostanie user. I robisz to w takim miejscu gdzie wiadomo co zwrócić, czyli zamiast twojego gdzieś w bebechach rzucić wyjątek NotFoundException robisz po prostu return Either.Left(new SomeError(418, "I'm a teapot!")). Gdzie ty widzisz jakąś różnicę? Może tak być że dany error nie wie co powinno polecieć w górę, ale to też nie problem, bo warstwa która wie zrobi sobie mapLeft() i ustawi co potrzebuje.


"Nie brookliński most, ale przemienić w jasny, nowy dzień najsmutniejszą noc - to jest dopiero coś!"
edytowany 2x, ostatnio: Shalom
krancki
  • Rejestracja:prawie 7 lat
  • Ostatnio:ponad 2 lata
  • Lokalizacja:74.7261832, -41.7409518
  • Postów:151
0

Usera w ogóle nie obchodzi ze error jest dlatego ze query do bazy sie popsuło

no zgadza się (nie wspominałem że usera takie informacje interesują) a nawet jeśli, to core nie powinien o tym decydować. Api powinno decydować co zwrócić użytkownikowi, jeżeli walidacja inputu użytkownika jest weryfikowana nisko np w warstwie core(wynika to z domeny), kilka warstw wyżej tak na strzała nie można powiedzieć czy to walidacja się nie udała czy połączenie do bazy danych. Ale idąc po stacku issuesów można to stwierdzić.
Dla błędu walidacji zwrócić info dla usera bez logów w pliku(bo to błąd użytkownika) albo że baza danych leży i wtedy leci spam do loggów a user dostaje info się nie udało stworzyć szablonu.

a nie że budujesz jakiś wielki graf a potem jakaś złożona logika robi analizę.

Nie buduję wielkiego grafu, tyle ile jest warstw taki może być ciąg wiązań.

a potem jakaś złożona logika robi analizę.

Kopiuj
class StrategiaMapingu implement StrategiaObsługiIsuesa{

public ErrorDto prepareErrorDto(ApiIssue<?> issue){
  return ErrorDto.builder()
    .type(issue.getType())
    .message(issue.getIssue())
    .causeBy(issue.findRootIssuesByTypes(Set.of(ZCORA.TO_ATTRIBUTE_MAPPING_ISSUE, ZCORA.TO_GENERIC_MAPPING_ISSUE)))
}

 public boolean canHandle(Issue<?> issue){
  return  ! issue.findRootIssuesByTypes(Set.of(ZCORA.TO_ATTRIBUTE_MAPPING_ISSUE, ZCORA.TO_GENERIC_MAPPING_ISSUE)).isEmpty()
}
}

"gdzieś w bebechach rzucić wyjątek NotFoundException" - to był nieśmieszny joke.

I robisz to w takim miejscu gdzie wiadomo co zwrócić, czyli zamiast twojego gdzieś w bebechach rzucić wyjątek NotFoundException robisz po prostu return Either.Left(new SomeError(418, "I'm a teapot!")). Gdzie ty widzisz jakąś różnicę?

No właśnie nie zawsze wiadomo co zwrócić, nie zrobisz Either.Left(new SomeError(418, "I'm a teapot!")) . W warstwie o jeden niżej poleciał błąd, nie oznacza że błąd wynika z jej warstwy, nie wiesz co poleciało, czy DatabaseIssue 10 warstw niżej czy ValidationIssue 3 warstwy niżej. Może powinno polecieć 500 a może 400

edytowany 9x, ostatnio: krancki
Zobacz pozostałe 5 komentarzy
krancki
Nieee mówiłem tu o dwóch różnych przypadkach, persstencja 10 warstw niżej która tak naprawdę nas nie obchodzi(Chyba że był by powód) i validacja 3 warstwy niżej z której szczegóły dlaczego validacja się nie udała jest z perspektywy użytkownika ważna
Riddle
@krancki: Może tak może nie. Zależy. Potrafię znaleźć case'y gdzie jest jaknajbardziej ważna (jeśli tylko nie zawiera szczegółów implementacyjnych), ale potrafię też znaleźć case'y gdzie nie jest.
Riddle
Mówię o tym że jeśli z biznesowego punktu widzenia np "Nie można podać pustego imienia do systemu", to nie należy polegać na checku "not empty" w bazie do tego, tylko trzeba to oklikać w domenie biznesowej.
krancki
@TomRiddle: Tak ale to developer decyduje o tym co chce użytkownikowi zwrócić, defaultowo tworzenie błędu bazowało by na błędzie najwyższej warstwy czyli "nie udało się utworzyć szablonu" ale też można zapewnić case "Nie udało się utworzyć szablonu bo validacja pola 1,2,3 nie przeszła z konkretnych powodów", a i jeżeli zechce zwrócić info z warstwy persystencji (Wiadomo tak się nie robi) to tak zrobi.
krancki
Cały problem który został tu ukazany skupiał się jak zastąpić exceptiony np eitherami (Lub jakimś innym podejściem) równocześnie zachowując ich funkcjonalność (Czyli na górę wynosimy informacje co dokładnie nie działa... taki stack trace). Zapewniając również możliwość obsługi specyficznych błędów niższych warstw w inny sposób niż tych z wyższego poziomu.
VD
  • Rejestracja:ponad 10 lat
  • Ostatnio:9 miesięcy
  • Postów:72
0
Shalom napisał(a):

robisz po prostu return Either.Left(new SomeError(418, "I'm a teapot!")).

Nie wiem czy dobrze zrozumiałem, ale sugerujesz, żeby zrobić to w ten sposób? Żeby inne warstwy wiedziały, że błąd będzie propagowany do HTTP i dlatego potrzebuje statusu? Ten błąd powinien być interpretowany na poziomie API (np. REST) i tam zamieniany na odpowiedni status i message.

krancki
" I robisz to w takim miejscu gdzie wiadomo co zwrócić" wydaje mi się że chodziło mu o api
Shalom
  • Rejestracja:około 21 lat
  • Ostatnio:prawie 3 lata
  • Lokalizacja:Space: the final frontier
  • Postów:26433
1

@VeloxDigitis chodzi mi o to, żeby dana warstwa zwracała w górę odpowiedni error, a nie pchała jakiś niskopoziomowy śmieć. Więc np. warstwa domeny woła jakieś repository.saveCośtam i się wywaliło, to nie pchamy dalej jakiegoś SQLException bo akurat nasze repository wrappuje bazę danych, tylko robimy sobie peekLeft(), logujemy co tam przyszło a potem robimy mapLeft() i zwracamy wyżej jakiś bardziej domenowy błąd, który to znów warstwa wyżej będzie umiała zinterpretować. W efekcie wiele różnych niskpoziomowych błędów może wychodzić z domeny jako ten sam typ błędu.
W domenie pewnie nie chcesz mieć http error code, ale nikt nie broni ci mieć hierarchii jakichś DomainError gdzie będzie ich kilka typów, albo mieć jakiegoś enuma który określa czy błąd jest w danych które przyszły, czy może infra nam sie wywaliła.

Dla mnie pomysł żeby pchać jako error cały stack (czyli de facto pchać tego niskopoziomowego śmiecia, bo przecież cała reszta z niego wynika) a potem nagle gdzieś na poziomie API pisać jakąś złożoną logikę która na podstawie tego stacku rozkmini co to za error, jest strasznie dziwne i wróżę że skończy się niewyobrażalnym spaghetti w tym twoim StrategiaMapingu. Bo na dobrą sprawę będziesz tam robić jakiś mirror logiki domenowej.


"Nie brookliński most, ale przemienić w jasny, nowy dzień najsmutniejszą noc - to jest dopiero coś!"
edytowany 1x, ostatnio: Shalom
VD
Jasne, dzięki - trochę źle zrozumiałem :)
Kliknij, aby dodać treść...

Pomoc 1.18.8

Typografia

Edytor obsługuje składnie Markdown, w której pojedynczy akcent *kursywa* oraz _kursywa_ to pochylenie. Z kolei podwójny akcent **pogrubienie** oraz __pogrubienie__ to pogrubienie. Dodanie znaczników ~~strike~~ to przekreślenie.

Możesz dodać formatowanie komendami , , oraz .

Ponieważ dekoracja podkreślenia jest przeznaczona na linki, markdown nie zawiera specjalnej składni dla podkreślenia. Dlatego by dodać podkreślenie, użyj <u>underline</u>.

Komendy formatujące reagują na skróty klawiszowe: Ctrl+B, Ctrl+I, Ctrl+U oraz Ctrl+S.

Linki

By dodać link w edytorze użyj komendy lub użyj składni [title](link). URL umieszczony w linku lub nawet URL umieszczony bezpośrednio w tekście będzie aktywny i klikalny.

Jeżeli chcesz, możesz samodzielnie dodać link: <a href="link">title</a>.

Wewnętrzne odnośniki

Możesz umieścić odnośnik do wewnętrznej podstrony, używając następującej składni: [[Delphi/Kompendium]] lub [[Delphi/Kompendium|kliknij, aby przejść do kompendium]]. Odnośniki mogą prowadzić do Forum 4programmers.net lub np. do Kompendium.

Wspomnienia użytkowników

By wspomnieć użytkownika forum, wpisz w formularzu znak @. Zobaczysz okienko samouzupełniające nazwy użytkowników. Samouzupełnienie dobierze odpowiedni format wspomnienia, zależnie od tego czy w nazwie użytkownika znajduje się spacja.

Znaczniki HTML

Dozwolone jest używanie niektórych znaczników HTML: <a>, <b>, <i>, <kbd>, <del>, <strong>, <dfn>, <pre>, <blockquote>, <hr/>, <sub>, <sup> oraz <img/>.

Skróty klawiszowe

Dodaj kombinację klawiszy komendą notacji klawiszy lub skrótem klawiszowym Alt+K.

Reprezentuj kombinacje klawiszowe używając taga <kbd>. Oddziel od siebie klawisze znakiem plus, np <kbd>Alt+Tab</kbd>.

Indeks górny oraz dolny

Przykład: wpisując H<sub>2</sub>O i m<sup>2</sup> otrzymasz: H2O i m2.

Składnia Tex

By precyzyjnie wyrazić działanie matematyczne, użyj składni Tex.

<tex>arcctg(x) = argtan(\frac{1}{x}) = arcsin(\frac{1}{\sqrt{1+x^2}})</tex>

Kod źródłowy

Krótkie fragmenty kodu

Wszelkie jednolinijkowe instrukcje języka programowania powinny być zawarte pomiędzy obróconymi apostrofami: `kod instrukcji` lub ``console.log(`string`);``.

Kod wielolinijkowy

Dodaj fragment kodu komendą . Fragmenty kodu zajmujące całą lub więcej linijek powinny być umieszczone w wielolinijkowym fragmencie kodu. Znaczniki ``` lub ~~~ umożliwiają kolorowanie różnych języków programowania. Możemy nadać nazwę języka programowania używając auto-uzupełnienia, kod został pokolorowany używając konkretnych ustawień kolorowania składni:

```javascript
document.write('Hello World');
```

Możesz zaznaczyć również już wklejony kod w edytorze, i użyć komendy  by zamienić go w kod. Użyj kombinacji Ctrl+`, by dodać fragment kodu bez oznaczników języka.

Tabelki

Dodaj przykładową tabelkę używając komendy . Przykładowa tabelka składa się z dwóch kolumn, nagłówka i jednego wiersza.

Wygeneruj tabelkę na podstawie szablonu. Oddziel komórki separatorem ; lub |, a następnie zaznacz szablonu.

nazwisko;dziedzina;odkrycie
Pitagoras;mathematics;Pythagorean Theorem
Albert Einstein;physics;General Relativity
Marie Curie, Pierre Curie;chemistry;Radium, Polonium

Użyj komendy by zamienić zaznaczony szablon na tabelkę Markdown.

Lista uporządkowana i nieuporządkowana

Możliwe jest tworzenie listy numerowanych oraz wypunktowanych. Wystarczy, że pierwszym znakiem linii będzie * lub - dla listy nieuporządkowanej oraz 1. dla listy uporządkowanej.

Użyj komendy by dodać listę uporządkowaną.

1. Lista numerowana
2. Lista numerowana

Użyj komendy by dodać listę nieuporządkowaną.

* Lista wypunktowana
* Lista wypunktowana
** Lista wypunktowana (drugi poziom)

Składnia Markdown

Edytor obsługuje składnię Markdown, która składa się ze znaków specjalnych. Dostępne komendy, jak formatowanie , dodanie tabelki lub fragmentu kodu są w pewnym sensie świadome otaczającej jej składni, i postarają się unikać uszkodzenia jej.

Dla przykładu, używając tylko dostępnych komend, nie możemy dodać formatowania pogrubienia do kodu wielolinijkowego, albo dodać listy do tabelki - mogłoby to doprowadzić do uszkodzenia składni.

W pewnych odosobnionych przypadkach brak nowej linii przed elementami markdown również mógłby uszkodzić składnie, dlatego edytor dodaje brakujące nowe linie. Dla przykładu, dodanie formatowania pochylenia zaraz po tabelce, mogłoby zostać błędne zinterpretowane, więc edytor doda oddzielającą nową linię pomiędzy tabelką, a pochyleniem.

Skróty klawiszowe

Skróty formatujące, kiedy w edytorze znajduje się pojedynczy kursor, wstawiają sformatowany tekst przykładowy. Jeśli w edytorze znajduje się zaznaczenie (słowo, linijka, paragraf), wtedy zaznaczenie zostaje sformatowane.

  • Ctrl+B - dodaj pogrubienie lub pogrub zaznaczenie
  • Ctrl+I - dodaj pochylenie lub pochyl zaznaczenie
  • Ctrl+U - dodaj podkreślenie lub podkreśl zaznaczenie
  • Ctrl+S - dodaj przekreślenie lub przekreśl zaznaczenie

Notacja Klawiszy

  • Alt+K - dodaj notację klawiszy

Fragment kodu bez oznacznika

  • Alt+C - dodaj pusty fragment kodu

Skróty operujące na kodzie i linijkach:

  • Alt+L - zaznaczenie całej linii
  • Alt+, Alt+ - przeniesienie linijki w której znajduje się kursor w górę/dół.
  • Tab/⌘+] - dodaj wcięcie (wcięcie w prawo)
  • Shit+Tab/⌘+[ - usunięcie wcięcia (wycięcie w lewo)

Dodawanie postów:

  • Ctrl+Enter - dodaj post
  • ⌘+Enter - dodaj post (MacOS)