Commit b0a08c25 authored by Carsten  Rose's avatar Carsten Rose
Browse files

Merge branch 'F7904RestAPIExportviaForm' into 'master'

F7904 rest api export via form

See merge request !126
parents 3d6ef9e4 8e49b671
Pipeline #1525 passed with stage
in 2 minutes and 6 seconds
......@@ -7590,6 +7590,96 @@ AutoCron / website: HTTPS protocol
* All certificates are accepted, even self signed without a correct chain or hostnames, not listed in the certificate.
This is useful if there is a general 'HTTP >> HTTPS' redirection configured and the website is accessed via `https://localhost/...`
.. _`rest`:
REST
----
QFQ offers an API endpoint for GET (and later POST,PUT,DELETE) operations. ::
<domain>/typo3conf/ext/qfq/Source/api/rest.php/<level1>/<id1>/<level2>/<id2>/.../?<var1>=<value1>&...
Append level names and ids after 'rest.php/...', separated by '/' each.
E.g.:
a) List of all persons: <domain>/typo3conf/ext/qfq/Source/api/rest.php/person
b) Data of person 123: <domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123
c) Adresses of person 123: <domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address
d) Adress details of address 45 from person 123: <domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address/45
QFQ 'Forms' are used as a 'container' to configure all necessary export/import details per 'level'.
Each 'level' is represented by a QFQ Form.
Only the last <level> of an URI will be processed. The former ones are just to fullfil a good looking REST API.
Important: the level name is the QFQ form name.
Each level name (=form name) is available via STORE_CLIENT and name '_formX'. E.g. in example
d) '{{_form1:C:alnumx}}'='person' and '{{_form2:C:alnumx}}'='address'.
Each level id is available via STORE_CLIENT and name '_idX'. E.g. in example
d) '{{_id1:C}}'='123' and '{{_id2:C}}'='45'.
Also the 'id' after the last 'level' in the URI path (123 in example b), and 45 in example d) ) is copied to
variable 'r' in STORE_TYPO3, access it via '{{r:T}}'.
Export (GET)
^^^^^^^^^^^^
All data is exported in JSON notation.
A REST (GET) form has two modes: ::
a) data: specific content to a given id. Defined via 'form.parameter.restSqlData'. This mode is selected
if there is an id>0 given.
b) list: a list of records will be exported. Defined via 'form.parameter.restSqlList'. This mode is selected if there is no id or id=0.
There are *no* FormElements.
To simplify access to id parameter of the URI, a mapping is possible via 'form.parameter.restParam'.
E.g. 'restParam=pId,adrId' with example d) makes '{{pId:C}}=123' and '{{adrId:C}}=45'. The order of variable
names corresponds to the position in the URI. _id1 is always mapped to the first parameter name, _id2 to
the second one and so on.
GET Variables provided via URL are available via STORE_CLIENT as usual.
Form:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| name=<level> | Level name in URI |
+-------------------+------------------------------------------------------------------------------+
| permitNew=rest | The form can be loaded in REST mode with mising parameter 'id' or 'id=0' |
+-------------------+------------------------------------------------------------------------------+
| permitEdit=rest | The form can be loaded in REST mode with parameter 'id' > 0 |
+-------------------+------------------------------------------------------------------------------+
Form.parameter:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| restSqlData | SQL query selects content shown in data mode. |
| | restSqlData={{!SELECT id, name, gender FROM Person WHERE id={{r:T0}} }} |
+-------------------+------------------------------------------------------------------------------+
| restSqlList | SQL query selects content shown in data mode. |
| | restSqlData={{!SELECT id, name FROM Person }} |
+-------------------+------------------------------------------------------------------------------+
| restParam | CSV list of variable names. |
| | restParam=pId,adrId |
+-------------------+------------------------------------------------------------------------------+
There are no `special-column-names`_ available in 'restSqlData' or 'restSqlList'. Especially there are no
SIPs possible, cause REST typically does not offer sessions/cookies which are needed for SIPs.
.. _applicationTest:
Application Test
......@@ -7727,7 +7817,7 @@ To offer an FE User the possibility to change the own T3 FE password, create a f
fe[2].class = action
fe[2].type = afterSave
fe[2].parameter = sqlAfter={{UPDATE {{dbNameT3:Y}}.fe_users SET password='{{myPassword:FE:all:p}} WHERE username='{{feUser:T}}' AND deleted=0
fe[2].parameter = sqlAfter={{UPDATE {{dbNameT3:Y}}.fe_users SET password='{{myPassword:FE:all:p}}' WHERE username='{{feUser:T}}' AND deleted=0
Call the form via SIP on an existing record. Often QFQ has an own table for persons and also the current user exist in T3
fe_users table.
......
<?php
/**
* Created by PhpStorm.
* User: crose
* Date: 17.02.19
* Time: 15:40
*/
namespace qfq;
use qfq;
require_once(__DIR__ . '/../core/QuickFormQuery.php');
require_once(__DIR__ . '/../core/exceptions/UserFormException.php');
require_once(__DIR__ . '/../core/exceptions/CodeException.php');
require_once(__DIR__ . '/../core/exceptions/DbException.php');
$restId=array();
$restForm=array();
try {
try {
$form = OnString::splitPathInfoToIdForm($_SERVER['PATH_INFO'], $restId, $restForm);
$id=end($restId);
// Fake Bodytext setup
$bodytext = TYPO3_RECORD_ID . '=' . $id . PHP_EOL;
$bodytext .= TYPO3_FORM . '=' . $form . PHP_EOL;
$qfq = new QuickFormQuery(['bodytext' => $bodytext]);
$answer = $qfq->rest($restId, $restForm);
} catch (qfq\CodeException $e) {
$answer[API_MESSAGE] = $e->formatMessage();
} catch (qfq\UserFormException $e) {
$answer[API_MESSAGE] = $e->formatMessage();
} catch (qfq\DbException $e) {
$answer[API_MESSAGE] = $e->formatMessage();
}
} catch (\Exception $e) {
$answer[API_MESSAGE] = "Generic Exception: " . $e->getMessage();
}
header("Content-Type: application/json");
echo json_encode($answer);
......@@ -32,16 +32,20 @@ const TABLE_NAME_FORM = 'Form';
const TABLE_NAME_FORM_ELEMENT = 'FormElement';
const TABLE_NAME_SPLIT = 'Split';
// Form Mode
const FORM_LOAD = 'form_load';
const FORM_SAVE = 'form_save';
const FORM_UPDATE = 'form_update';
const FORM_DELETE = 'form_delete';
const FORM_DRAG_AND_DROP = 'form_drag_and_drop';
const FORM_REST = 'form_rest';
const FORM_PERMISSION_SIP = 'sip';
const FORM_PERMISSION_LOGGED_IN = 'logged_id';
const FORM_PERMISSION_LOGGED_OUT = 'logged_out';
const FORM_PERMISSION_ALWAYS = 'always';
const FORM_PERMISSION_NEVER = 'never';
const FORM_PERMISSION_REST = 'rest';
const FORM_BUTTON_NEW = 'new';
const FORM_BUTTON_DELETE = 'delete';
const FORM_BUTTON_CLOSE = 'close';
......@@ -223,6 +227,7 @@ const ERROR_SMALLER_THAN_MIN = 1083;
const ERROR_LARGER_THAN_MAX = 1084;
const ERROR_INVALID_DECIMAL_FORMAT = 1085;
const ERROR_INVALID_DATE = 1086;
const ERROR_FORM_REST = 1087;
// Subrecord
const ERROR_SUBRECORD_MISSING_COLUMN_ID = 1100;
......@@ -983,6 +988,12 @@ const F_ORDER_COLUMN_NAME = 'ord';
const F_SHOW_ID_IN_FORM_TITLE = SYSTEM_SHOW_ID_IN_FORM_TITLE;
const F_REST_SQL_LIST = 'restSqlList';
const F_REST_SQL_DATA = 'restSqlData';
const F_REST_PARAM = 'restParam';
const CLIENT_REST_ID = '_id';
const CLIENT_REST_FORM = '_form';
// FORM_ELEMENT_STATI
const FE_MODE_SHOW = 'show';
const FE_MODE_READONLY = 'readonly';
......
......@@ -398,10 +398,13 @@ class QuickFormQuery {
// For 'new' record always create a new Browser TAB-uniq (for this current form, nowhere else used) SIP.
// With such a Browser TAB-uniq SIP, multiple Browser TABs and following repeated NEWs are easily implemented.
if (!$sipFound || ($formMode == FORM_LOAD && $recordId === 0)) {
$this->store->createSipAfterFormLoad($formName);
if ($formMode != FORM_REST) {
if (!$sipFound || ($formMode == FORM_LOAD && $recordId === 0)) {
$this->store->createSipAfterFormLoad($formName);
}
}
// Fill STORE_BEFORE
if ($this->store->getVar($this->formSpec[F_PRIMARY_KEY], STORE_BEFORE) === false) {
$this->store->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $recordId,
$this->dbArray[$this->dbIndexData], $this->formSpec[F_PRIMARY_KEY], STORE_BEFORE);
......@@ -422,7 +425,7 @@ class QuickFormQuery {
}
}
// FORM_LOAD: if there is an foreign exclusive record lock - show form in F_MODE_READONLY mode.
// FORM_LOAD: if there is a foreign exclusive record lock - show form in F_MODE_READONLY mode.
if ($formMode === FORM_LOAD) {
$dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq);
$recordDirty = array();
......@@ -432,27 +435,36 @@ class QuickFormQuery {
}
}
if ($formMode === FORM_DELETE) {
$build = new Delete($this->dbIndexData);
} else {
$tableDefinition = $this->dbArray[$this->dbIndexData]->getTableDefinition($this->formSpec[F_TABLE_NAME]);
$this->store->fillStoreTableDefaultColumnType($tableDefinition);
switch ($formMode) {
case FORM_DELETE:
$build = new Delete($this->dbIndexData);
break;
case FORM_REST:
break;
case FORM_LOAD:
case FORM_SAVE:
case FORM_UPDATE:
case FORM_DRAG_AND_DROP:
switch ($this->formSpec['render']) {
case 'plain':
$build = new BuildFormPlain($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
case 'table':
$build = new BuildFormTable($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
case 'bootstrap':
$build = new BuildFormBootstrap($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
default:
throw new CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
}
$tableDefinition = $this->dbArray[$this->dbIndexData]->getTableDefinition($this->formSpec[F_TABLE_NAME]);
$this->store->fillStoreTableDefaultColumnType($tableDefinition);
switch ($this->formSpec['render']) {
case 'plain':
$build = new BuildFormPlain($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
case 'table':
$build = new BuildFormTable($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
case 'bootstrap':
$build = new BuildFormBootstrap($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
break;
default:
throw new CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
}
break;
default:
throw new CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
}
$formAction = new FormAction($this->formSpec, $this->dbArray[$this->dbIndexData], $this->phpUnit);
......@@ -543,7 +555,6 @@ class QuickFormQuery {
if ($getJson) {
// Values of FormElements might be changed during 'afterSave': rebuild the form to load the new values. Especially for non primary template groups.
// $this->loadFormSpecification($formMode, $recordId, $foundInStore);
$feSpecNative = $this->getNativeFormElements(SQL_FORM_ELEMENT_NATIVE_TG_COUNT, [$this->formSpec[F_ID]], $this->formSpec);
$parameterLanguageFieldName = $this->store->getVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, STORE_SYSTEM);
$feSpecNative = HelperFormElement::setLanguage($feSpecNative, $parameterLanguageFieldName);
......@@ -566,6 +577,11 @@ class QuickFormQuery {
$formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
break;
case FORM_REST:
$data = $this->doRestGet();
$flagApiStructureReGroup = false;
break;
default:
throw new CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
}
......@@ -578,6 +594,56 @@ class QuickFormQuery {
return $data;
}
/**
* @param array $restIds
* @return array
* @throws CodeException
* @throws DbException
* @throws UserFormException
* @throws UserReportException
*/
private function doRestGet() {
$this->copyGenericRestParamToNamed();
$r = $this->store::getVar(TYPO3_RECORD_ID, STORE_TYPO3);
$key = empty($r) ? F_REST_SQL_LIST : F_REST_SQL_DATA;
if (!isset($this->formSpec[$key])) {
throw new UserFormException("Missing Parameter '$key'", ERROR_INVALID_VALUE);
}
return $this->evaluate->parse($this->formSpec[$key]);
}
/**
* STORE_CLIENT: copy parameter _id1,_id2,...,_idN to named variables, specified via $this->formSpec[F_REST_PARAM] (CSV list)
*
* @throws CodeException
* @throws UserFormException
* @throws UserReportException
*/
private function copyGenericRestParamToNamed() {
$paramNames = explode(',', $this->formSpec[F_REST_PARAM] ?? '');
$ii = 1;
foreach ($paramNames as $key) {
switch ($key) {
case CLIENT_FORM:
case CLIENT_RECORD_ID:
throw new UserFormException("Name '$key' is forbidden in " . F_REST_PARAM, ERROR_INVALID_VALUE);
break;
default:
break;
}
$val = $this->store::getVar(CLIENT_REST_ID . $ii, STORE_CLIENT);
$this->store::setVar($key, $val, STORE_CLIENT);
$ii++;
}
}
/**
* Copies state 'hidden' from a FE pill to all FE child elements of that pill.
*
......@@ -838,7 +904,7 @@ class QuickFormQuery {
* Loaded 'action' FormElements are in $this->feSpecAction
* Loaded 'native' FormElements are in $this->feSpecNative
*
* @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE
* @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE|FORM_REST
* @param int $recordId
* @param string $foundInStore
* @param string $formLogMode
......@@ -873,7 +939,7 @@ class QuickFormQuery {
// Check if there is a recordId specified in Bodytext - as variable or query.
$rTmp = $this->store->getVar(CLIENT_RECORD_ID, STORE_TYPO3, SANITIZE_ALLOW_ALL);
if (false !== $rTmp && !is_int($rTmp)) {
if (false !== $rTmp && !ctype_digit($rTmp)) {
$rTmp = $this->evaluate->parse($rTmp);
$this->store->setVar(CLIENT_RECORD_ID, $rTmp, STORE_TYPO3);
}
......@@ -886,10 +952,6 @@ class QuickFormQuery {
$form = $this->checkFormLogMode($form);
$form = $this->modeCleanFormConfig($mode, $form);
// Save specific elements to be expanded later.
$parseLater = OnArray::getArrayItems($form, [F_FORWARD_PAGE]);
$form[F_FORWARD_PAGE] = '';
HelperFormElement::explodeParameter($form, F_PARAMETER);
unset($form[F_PARAMETER]);
if (isset($form[FE_FILL_STORE_VAR])) {
......@@ -897,6 +959,12 @@ class QuickFormQuery {
unset($form[FE_FILL_STORE_VAR]);
}
// Save specific elements to be expanded later.
$parseLater = OnArray::getArrayItems($form, [F_FORWARD_PAGE, F_REST_SQL_LIST, F_REST_SQL_DATA]);
$form[F_FORWARD_PAGE] = '';
$form[F_REST_SQL_LIST] = '';
$form[F_REST_SQL_DATA] = '';
// Setting defaults later is too late.
if (empty($form[F_DB_INDEX])) {
$form[F_DB_INDEX] = $this->dbIndexData;
......@@ -1104,7 +1172,7 @@ class QuickFormQuery {
* Specified in SIP
*
*
* @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE
* @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE|FORM_REST
* @param string $foundInStore
*
* @return bool|string Formname (Form.name) or FALSE (if no formname found)
......@@ -1118,6 +1186,7 @@ class QuickFormQuery {
switch ($mode) {
case FORM_LOAD:
case FORM_REST:
$store = STORE_TYPO3;
break;
case FORM_SAVE:
......@@ -1240,7 +1309,7 @@ class QuickFormQuery {
$formSpec[F_FE_LABEL_ALIGN] = $this->store->getVar(SYSTEM_LABEL_ALIGN, STORE_SYSTEM . STORE_EMPTY);
}
$storeSystem=$this->store::getStore(STORE_SYSTEM);
$storeSystem = $this->store::getStore(STORE_SYSTEM);
foreach ($keys as $key) {
......@@ -1248,7 +1317,7 @@ class QuickFormQuery {
$this->store->setVar($key, $formSpec[$key], STORE_SYSTEM);
} else {
// if not found set ''
$formSpec[$key] = $storeSystem[$key]??'';
$formSpec[$key] = $storeSystem[$key] ?? '';
}
}
......@@ -1357,10 +1426,20 @@ class QuickFormQuery {
break;
case FORM_PERMISSION_NEVER:
throw new UserFormException("Loading form forbidden.", ERROR_FORM_FORBIDDEN);
break;
case FORM_PERMISSION_REST:
if ($formMode != FORM_REST) {
throw new UserFormException("Try to load a REST form", ERROR_FORM_REST);
}
break;
default:
throw new CodeException("Unknown permission mode: '" . $permitMode . "'", ERROR_FORM_UNKNOWN_PERMISSION_MODE);
}
if ($formMode == FORM_REST && $permitMode != FORM_PERMISSION_REST) {
throw new UserFormException("Try to load a non-REST form in REST mode", ERROR_FORM_REST);
}
// Form Definition valid?
if ($this->formSpec['multiMode'] !== 'none' && $this->formSpec['multiSql'] === '') {
throw new UserFormException("MultiMode selected, but MultiSQL missing", ERROR_MULTI_SQL_MISSING);
......@@ -1746,5 +1825,37 @@ EOF;
}
/**
* @param array $restId
* @param array $restForm
* @return array|string
* @throws CodeException
* @throws DbException
* @throws DownloadException
* @throws UserFormException
* @throws UserReportException
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function rest(array $restId, array $restForm) {
// Copy 'id' from REST Url to STORE_CLIENT. Naming is generic with '_idX'
$ii = 1;
foreach ($restId as $val) {
$this->store::setVar(CLIENT_REST_ID . $ii, $val, STORE_CLIENT);
$ii++;
}
// Copy 'form' from REST Url to STORE_CLIENT. Naming is generic with '_formX'
$ii = 1;
foreach ($restForm as $val) {
$this->store::setVar(CLIENT_REST_FORM . $ii, $val, STORE_CLIENT);
$ii++;
}
return $this->doForm(FORM_REST);
}
}
\ No newline at end of file
......@@ -94,11 +94,11 @@ class OnString {
* @param &$row - return the digit part of $pos
* @return bool - true if a alpha string and a numeric string is found, else false.
*/
public static function splitExcelPos($pos, &$column, &$row){
public static function splitExcelPos($pos, &$column, &$row) {
preg_match_all('/[A-Z]+|\d+/', $pos, $matches);
if(count($matches[0])!=2) {
if (count($matches[0]) != 2) {
return false;
}
......@@ -164,7 +164,7 @@ class OnString {
$nestingStart = 0;
// Process the string one start/end delimiter at a time
while(true) {
while (true) {
// find the next start/end delimiter
$nextDelimStartPos = strpos($str, $delimStart, $lastDelimPos + strlen($delimStart));
$nextDelimEndPos = strpos($str, $delimEnd, $lastDelimPos + strlen($delimEnd));
......@@ -183,7 +183,6 @@ class OnString {
ERROR_MESSAGE_SUPPORT => "in '$str'"]), ERROR_MISSING_OPEN_DELIMITER);
break;
} elseif ($exprDepth == 0) {
// end of nesting -> replace \n inside nested expression with space
......@@ -199,11 +198,74 @@ class OnString {
$lastDelimPos = $nextDelimPos;
}
if ($exprDepth > 0 ) {
if ($exprDepth > 0) {
throw new UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "Missing close delimiter '$delimEnd'",
ERROR_MESSAGE_SUPPORT => "in '$str'"]), ERROR_MISSING_CLOSE_DELIMITER);
}
return $str;
}
/**
* Split a $_SERVER['PATH_INFO'] of the form '/form1/id1/form2/id2/form3/id3/.../formN[/idN])' to
* $rcArrrIds=[ id1, id2, ..., idN]
* return: 'formN'
*
* @param $pathInfo
* @param array $rcArrId
* @param array $rcArrForm
* @return string
* @throws UserFormException
*/
public static function splitPathInfoToIdForm($pathInfo, array &$rcArrId, array &$rcArrForm) {
// Empty: do nothing
if ($pathInfo == '') {
return '';
}
// Remove optional leading '/'
if ($pathInfo[0] == '/') {
$pathInfo = substr($pathInfo, 1);
}
// Remove optional trailing '/'
$len = strlen($pathInfo);
if ($len > 0 && $pathInfo[$len - 1] == '/') {
$pathInfo = substr($pathInfo, 0, $len - 1);
}
// Empty? do nothing
if ($pathInfo == '') {
return '';
}
$param = explode('/', $pathInfo);
$cnt = count($param);
// No 'id'. Append '0'
if ($cnt % 2 == 1) {
array_push($param, 0);
}
$rcArrId = array();
$rcArrForm = array();
while (count($param)>0) {
$form= array_shift($param);
if (!ctype_alnum($form)) {
throw new UserFormException('Expect alphanumeric string', ERROR_BROKEN_PARAMETER);
}
$rcArrForm[]=$form;
$id = array_shift($param);
if (!ctype_digit((string) $id)) {
throw new UserFormException('Expect numerical id', ERROR_BROKEN_PARAMETER);
}
$rcArrId[] = $id;
}
return $form;
}
}
......@@ -185,10 +185,15 @@ INSERT INTO FormElement (formId, name, label, mode, type, checkType, class, ord,
checkPattern)
VALUES
# Make the form a 'delete form' for records Form/FormElement.
(1, 'Delete FE', '', 'show', 'beforeDelete', 'all', 'action', 10, 0, 0, '', '', '', '',
(1, '', 'Delete FE', 'show', 'beforeDelete', 'all', 'action', 10, 0, 0, '', '', '', '',
'sqlAfter={{DELETE FROM FormElement WHERE formId={{id:R}} }}',
0, '', '', '', 'none', 'no', ''),