001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.views;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.http.response.*;
024import org.apache.juneau.marshall.collections.*;
025
026/**
027 * The action-envelope submit of a row job: {@code {action, targetId, idempotencyKey, fields}}, the shape the
028 * row-action dialog and the job-log card's {@code envelope: "action"} both send.
029 *
030 * <p>
031 * {@link #parse(JsonMap)} checks the shape; {@link #verify(JobKeyLedger, String, String)} checks the action, then the
032 * key's recorded binding.  The row identity is {@link #targetId()} plus that binding &mdash; never anything in
033 * {@link #fields()}, which is user-tamperable request data.  An endpoint that also carries the row id in its path
034 * should check {@code targetId().equals(pathId)} first.
035 *
036 * <h5 class='section'>Example:</h5>
037 * <p class='bjava'>
038 *    <jk>var</jk> <jv>submit</jv> = RowJobSubmit.<jsm>parse</jsm>(<jv>json</jv>);
039 *    <jk>if</jk> (! <jv>submit</jv>.targetId().equals(<jv>id</jv>))
040 *       <jk>return</jk> refuse(<jv>res</jv>, RowJobSubmit.<jsm>bindingRefusal</jsm>());
041 *    <jk>var</jk> <jv>grant</jv> = <jv>submit</jv>.verify(<jv>jobKeys</jv>, <js>"restock-species"</js>, <jv>actor</jv>);
042 *    <jk>if</jk> (<jv>grant</jv>.isEmpty())
043 *       <jk>return</jk> refuse(<jv>res</jv>, RowJobSubmit.<jsm>bindingRefusal</jsm>());  <jc>// answered 409</jc>
044 * </p>
045 *
046 * <h5 class='section'>See Also:</h5>
047 * <ul>
048 *    <li class='jc'>{@link JobKeyLedger}
049 * </ul>
050 *
051 * @param action The submitted action id.
052 * @param targetId The submitted row id.
053 * @param idempotencyKey The submitted key value.
054 * @param fields The submitted fields; empty when the body omitted them.
055 *
056 * @since 10.0.0
057 */
058public record RowJobSubmit(String action, String targetId, String idempotencyKey, JsonMap fields) {
059
060   /** The HTTP status to answer {@link #bindingRefusal()} with. */
061   public static final int REFUSAL_STATUS = 409;
062
063   /**
064    * Parses the envelope.
065    *
066    * @param body The request body.
067    * @return The submit.
068    * @throws BadRequest On a non-object body, a blank {@code action}, {@code targetId} or {@code idempotencyKey}, or
069    *    a {@code fields} member that is not an object.  A missing {@code fields} reads as empty.
070    */
071   public static RowJobSubmit parse(JsonMap body) {
072      if (body == null)
073         throw new BadRequest("Expected a JSON object body");
074      var action = text(body, "action");
075      var targetId = text(body, "targetId");
076      var key = text(body, "idempotencyKey");
077      var f = body.get("fields");
078      JsonMap fields;
079      if (f == null)
080         fields = new JsonMap();
081      else if (f instanceof Map<?,?> m)
082         fields = new JsonMap(m);
083      else
084         throw new BadRequest("'fields' must be an object");
085      return new RowJobSubmit(action, targetId, key, fields);
086   }
087
088   private static String text(JsonMap body, String name) {
089      var v = body.get(name);
090      if (! (v instanceof CharSequence cs) || cs.toString().isBlank())
091         throw new BadRequest("Missing '%s'", name);
092      return cs.toString();
093   }
094
095   /**
096    * Checks the action, then the key's binding to {@code (action, targetId)}.
097    *
098    * @param ledger The ledger the key was issued from.
099    * @param expectedAction The action this endpoint performs.
100    * @return The grant, or empty when the action differs or the key does not verify.
101    */
102   public Optional<JobKeyLedger.Grant> verify(JobKeyLedger ledger, String expectedAction) {
103      if (neq(action, expectedAction))
104         return Optional.empty();
105      return ledger.verify(idempotencyKey, action, targetId);
106   }
107
108   /**
109    * Checks the action, then the key's binding to {@code (action, targetId, principal)}.
110    *
111    * @param ledger The ledger the key was issued from.
112    * @param expectedAction The action this endpoint performs.
113    * @param principal The actor, resolved the same way the key endpoint resolved it.
114    * @return The grant, or empty when the action differs or the key does not verify.
115    */
116   public Optional<JobKeyLedger.Grant> verify(JobKeyLedger ledger, String expectedAction, String principal) {
117      if (neq(action, expectedAction))
118         return Optional.empty();
119      return ledger.verify(idempotencyKey, action, targetId, principal);
120   }
121
122   /**
123    * The standard refusal for a failed verify; answer it with {@link #REFUSAL_STATUS}.
124    *
125    * <p>
126    * One code and one message for every kind of miss (see {@link JobKeyLedger}).
127    *
128    * @return A new refusal.
129    */
130   public static ActionResult bindingRefusal() {
131      return ActionResult.refusal(JobKeyLedger.REFUSAL_CODE).message("This key is not valid for this row.");
132   }
133
134   @Override /* Object */
135   public String toString() {
136      return "RowJobSubmit(action=" + action + ",targetId=" + targetId + ",idempotencyKey=<redacted>,fields=" + fields + ")";
137   }
138}